Merge pull request 'Add svc auto-recovery: autostart desired-state, recover, watchdog' (#396) from feature/svc-autorecover into main
ci / test-pg (push) Successful in 1m0s
ci / test (push) Failing after 1m4s

This commit was merged in pull request #396.
This commit is contained in:
2026-10-11 22:07:39 +00:00
5 changed files with 1133 additions and 17 deletions
File diff suppressed because one or more lines are too long
+9 -1
View File
@@ -13,7 +13,7 @@ PLUGINS := plugins
COMBO := core=$(shell git -C $(CORE) rev-parse --short HEAD 2>/dev/null)+backend=$(shell git -C $(BACKEND) rev-parse --short HEAD 2>/dev/null)+frontend=$(shell git -C $(FRONTEND) rev-parse --short HEAD 2>/dev/null)+ui=$(shell git -C $(UI) rev-parse --short HEAD 2>/dev/null)+agentcmd=$(shell git -C deps/agent-command rev-parse --short HEAD 2>/dev/null)+documents=$(shell git -C modules/documents rev-parse --short HEAD 2>/dev/null)+native=$(shell git -C deps/agent-native rev-parse --short HEAD 2>/dev/null)+squad=$(shell git -C deps/agent-squad rev-parse --short HEAD 2>/dev/null)+intent=$(shell git -C deps/agent-intent rev-parse --short HEAD 2>/dev/null)
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null)+$(COMBO)
.PHONY: help sync status build frontend svc svc-list svc-info svc-commits svc-history svc-update test-backend test-backend-pg test-frontend test-ui test-core test-agentcmd test-pins test-libs dev pin clean setup precheck task-start task-sync task-close task-finish facts-verify evidence version
.PHONY: help sync status build frontend svc svc-list svc-info svc-commits svc-history svc-update svc-recover svc-watchdog test-backend test-backend-pg test-frontend test-ui test-core test-agentcmd test-pins test-libs dev pin clean setup precheck task-start task-sync task-close task-finish facts-verify evidence version
version:
@echo "agent-system $(VERSION)"
@@ -67,6 +67,8 @@ help:
@echo "make svc-commits 列出可部署提交:make svc-commits SRC=dev|release [N=20]"
@echo "make svc-history 事件历史:make svc-history [ENV] [N=20](完整文档:docs/svc.md)"
@echo "make svc-update 自动更新过时服务:make svc-update [NAMES='dev pg1'] [ARGS='--check --all']"
@echo "make svc-recover 自恢复:拉回 autostart=1 但已死的服务:make svc-recover [ARGS='dev --quiet']"
@echo "make svc-watchdog 自恢复看护:make svc-watchdog ARGS='install|remove|status'(systemd 用户 timer)"
@echo "make dev 开发模式说明(前后端分离热更新)"
@echo "make pin 推进 submodule 指针到各仓远端 main(提交前须 make test 通过)"
@echo "make status submodule 状态"
@@ -175,6 +177,12 @@ svc-commits:
svc-history:
scripts/svc.sh history $(ENV) $(N)
svc-recover:
scripts/svc.sh recover $(ARGS)
svc-watchdog:
scripts/svc.sh watchdog $(ARGS)
svc-update:
scripts/svc.sh update $(NAMES) $(ARGS)
+59 -2
View File
@@ -460,14 +460,71 @@ scripts/svc.sh update [name...] [--check] [--all] [--no-fetch]
任一服务失败不中断其余服务;最终汇总 `成功/失败/跳过`。退出码:0 全部成功或最新;
1 有失败;2 用法错误。
### 6.5 help
### 6.5 recover(自恢复:拉回死亡服务)
```bash
scripts/svc.sh recover # 扫描全部已登记服务
scripts/svc.sh recover dev pg1 # 点名恢复
scripts/svc.sh recover --quiet # 静默(watchdog timer 用)
```
svc 服务是裸后台进程(nohup):进程崩溃、被 OOM killer 杀掉或机器重启后,
**没有人把它拉回来**——recover 补上这一环。
判定依据是 svc.env 的 **autostart 期望态键**:
| 动作 | autostart |
|---|---|
| start / restart / deploy / update 健康检查通过 | 置 `1`(期望在运行) |
| 显式 `stop`(含 deploy/update 的前置停机) | 移除(保持停止是明确意图) |
| 进程自行死亡(崩溃 / OOM 被杀 / 重启后) | 保留 → recover 据此拉回 |
| `rm` 注销 | 键随 svc.env 消失,不再触碰 |
行为细节:
- 扫描顺序 postgres 在前、agent/static 在后(绑定实例先起,agent 重建 DSN 才有效);
- 只对 `autostart=1` 且 pidfile+cmdline 校验已死的服务执行**裸 start**(复用持久化
`exec=` 与数据库绑定,不改任何配置、不重建产物);
- 逐服务持 `runtime/<name>/.start.lock`(非阻塞):deploy(含构建窗口)/手动 start
持锁期间跳过本轮,不抢跑;
- 每次尝试写 `recover ok|fail` 事件(`history` 可追溯);进程反复即退时自然限频
约 1 次/分钟(健康探测 20s + timer 周期);
- deploy/update **失败**(构建失败、预检拒绝)→ 服务保持停止等人工介入,watchdog
不盲目复活(前置停机已清 autostart);
- 端口被外部进程占用时 start 报错放弃(不抢不杀),下轮重试。
### 6.6 watchdog(自恢复看护 timer)
```bash
scripts/svc.sh watchdog install # 安装并启用 systemd 用户 timer(每分钟 recover)
scripts/svc.sh watchdog install --print # 只打印单元文件与计划
scripts/svc.sh watchdog remove # 停用并删除 timer
scripts/svc.sh watchdog status # timer 状态 + autostart=1 的服务清单
```
- 单元写入 `~/.config/systemd/user/agent-system-svc-recover.{service,timer}`
(`OnBootSec=1min` + `OnUnitActiveSec=60s`),`ExecStart=<本仓>/scripts/svc.sh recover --quiet`;
- 开机自启依赖 linger(`sudo loginctl enable-linger <user>`);未开时安装会提示,
登出不受影响(KillUserProcesses 默认关,进程本就不随登出死);
- 换检出目录后重装一次即可(status 会提示 timer 指向的 ROOT 与当前不一致);
- 无 systemd 用户会话的环境(如某些容器)install 会拒绝并给出 cron 降级命令:
`* * * * * <ROOT>/scripts/svc.sh recover --quiet >/dev/null 2>&1`。
一键自恢复配方:
```bash
scripts/svc.sh dev deploy # 启动成功 → autostart=1
scripts/svc.sh watchdog install # 之后 kill -9 / OOM / 重启,约 1 分钟内自动拉回
```
### 6.7 help
```bash
scripts/svc.sh # 一屏速览(短帮助)
scripts/svc.sh -h # 同上
scripts/svc.sh --help # 完整帮助(全部命令/参数/约束)
scripts/svc.sh help # 分主题清单
scripts/svc.sh help <主题> # 主题详解:model deploy lifecycle db dbops config examples safety
scripts/svc.sh help <主题> # 主题详解:model deploy lifecycle db dbops config recovery examples safety
```
## 7. 命令参考:数据库
+320 -14
View File
@@ -23,6 +23,9 @@
# scripts/svc.sh commits [dev|release|rdm] [N] 可部署提交列表(默认 dev,N=20)
# scripts/svc.sh history [name] [N] 事件历史(默认全部,最近 20 条)
# scripts/svc.sh update [name] [--check] [--all] 检测远端最新并更新过时服务(与当前工作区状态无关)
# scripts/svc.sh recover [name] [--quiet] 自恢复:把 autostart=1 但已死的服务重新拉起
# (崩溃/OOM 被杀/重启后由 watchdog 定时调用)
# scripts/svc.sh watchdog install|remove|status 自恢复看护:systemd 用户级 timer(每分钟 recover)
# scripts/svc.sh help [主题] 分主题详细帮助
#
# ════════════════════ 部署来源(agent/static 的 deploy)════════════════════
@@ -72,6 +75,7 @@
# 其余按名字确定性派生(8800-9099,避让已登记/监听端口),SVC_<NAME>_ADDR 可覆盖
# - runtime/<name>/:bin|htdocs|data|pgdata|plugins|backups + svc.pid +
# svc.log|pg.log + svc.env(登记+持久化配置,600)+ history.jsonl(事件流)
# + .start.lock(生命周期互斥锁,recover 与 deploy/手动操作防竞态)
# - kind:agent(后端单二进制,内嵌前端)/ static(前端静态服务)/ postgres(数据库);
# 无 kind= 的旧登记自动视为 agent;start/restart 使用环境内产物
#
@@ -94,6 +98,8 @@
# scripts/svc.sh db restore pg2 --file <dump> --db app --force
# scripts/svc.sh list --tag core && scripts/svc.sh history
# scripts/svc.sh info && scripts/svc.sh info pg1 --reveal # 全景报告 / 单服务深度(含掩码值)
# scripts/svc.sh watchdog install # 装看护 timer:崩溃/OOM 被杀后自动拉回
# kill -9 <pid> && sleep 65 && scripts/svc.sh list # start 成功记 autostart=1,一分钟内自动恢复
# scripts/svc.sh pg1 restart && scripts/svc.sh rm tmp1 --purge
# make svc ENV=dev ARGS="--env .env.dev --port 9000"
#
@@ -128,8 +134,11 @@ RDM_SRC_DIR="${SVC_RDM_SRC_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/agent-system-svc
RDM_ORIGIN_DEFAULT="ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-RDM.git"
# 提示信息走 stderr:stdout 只留给数据(提交表/服务表/DSN),便于 $( ) 捕获组合。
c_info() { printf '\033[1;34m[svc]\033[0m %s\n' "$*" >&2; }
c_ok() { printf '\033[1;32m[svc]\033[0m %s\n' "$*" >&2; }
c_info() { [ "${CLI_QUIET:-0}" = 1 ] && return 0; printf '\033[1;34m[svc]\033[0m %s\n' "$*" >&2; }
c_ok() { [ "${CLI_QUIET:-0}" = 1 ] && return 0; printf '\033[1;32m[svc]\033[0m %s\n' "$*" >&2; }
# c_event:事件级输出,不受 --quiet 抑制——watchdog 静默周期里有实际拉回/失败
# 仍要在 journal 留痕(跳过类的噪声才被 --quiet 吞掉)。
c_event() { printf '\033[1;36m[svc]\033[0m %s\n' "$*" >&2; }
c_warn() { printf '\033[1;33m[svc]\033[0m %s\n' "$*" >&2; }
c_err() { printf '\033[1;31m[svc]\033[0m %s\n' "$*" >&2; }
@@ -157,6 +166,8 @@ svc.sh —— 本机服务管理:后端(agent) / 前端(static) / 数据库(po
scripts/svc.sh commits [dev|release] [N] 可部署提交
scripts/svc.sh history [name] [N] 事件历史
scripts/svc.sh update [name] [--check] 刷新过时服务到远端最新(不依赖工作区状态)
scripts/svc.sh recover [name] [--quiet] 自恢复:拉回 autostart=1 但已死的服务
scripts/svc.sh watchdog install|remove|status 自恢复看护(systemd 用户 timer,每分钟 recover)
帮助:--help 完整帮助 | help [主题] 分主题(model deploy lifecycle db dbops config examples safety)
文档:docs/svc.md
@@ -164,7 +175,7 @@ EOF
}
# HELP_TOPICS:help 的主题清单(cmd_help 与提示共用)。
HELP_TOPICS="model deploy lifecycle db dbops config examples safety"
HELP_TOPICS="model deploy lifecycle db dbops config recovery examples safety"
cmd_help() {
local topic="${1:-}"
@@ -203,6 +214,7 @@ update deploy stop start restart logs help info version。
svc.log | pg.log 日志(postgres 用 pg.log)
svc.env 登记 + 持久化配置(dotenv,600)
history.jsonl 事件流(append-only,一行一个 JSON)
.start.lock 生命周期互斥锁(recover 与 deploy/手动 start 防竞态)
svc.env 字段:
kind agent|static|postgres(旧登记无此键视为 agent)
@@ -217,6 +229,8 @@ svc.env 字段:
pidcheck 进程判定标记(agent=二进制路径、postgres=pgdata 路径、static=--directory)
exec agent 上次完整启动参数(裸 start/restart 复用;显式参数出现即重建)。
只存非敏感参数——DSN 不在其中,数据库配置走 db=/dbref=/dsn= 专键
autostart 期望运行态(成功 start 置 1;显式 stop 移除;进程死亡保留——
recover/watchdog 据此拉回,见 help recovery)
db agent 数据库模式:pg | sqlite(显式解绑)| mock;从未配置则无此键
dbref agent 托管数据库绑定 <pg名>/<库名>(启动时由实例登记重建 DSN)
dsn agent 外部直连 DSN(受保护,600;未命中本机实例时使用)
@@ -227,6 +241,7 @@ history.jsonl 事件(scripts/svc.sh history 查看):
create db create / 物理克隆登记
deploy begin|ok|fail(含来源)
start ok|fail|warn(fail 含原因:进程即退/健康检查超时)
recover ok|fail(watchdog/手动 recover 拉回死亡服务)
stop ok|fail
set 登记变更(addr 旧 → 新)
backup ok|fail(文件与大小)
@@ -324,6 +339,10 @@ EOF
scripts/svc.sh rm <name> 停止并移除登记(目录/数据/历史保留)
scripts/svc.sh rm <name> --purge 连 runtime/<name>/ 整个目录一起删除
自动恢复(崩溃 / OOM 被杀 / 机器重启后自动拉回;详见 help recovery):
scripts/svc.sh recover [name] [--quiet] 把 autostart=1 但已死的服务重新拉起
scripts/svc.sh watchdog install systemd 用户 timer 每分钟执行 recover
自动更新(一条命令把服务刷到远端最新;与当前工作区状态无关):
scripts/svc.sh update --check 只报告:每服务 当前来源 vs 最新(fetch 后比较)
scripts/svc.sh update 更新所有"过时且运行中"的服务
@@ -451,6 +470,39 @@ dotenv 格式:KEY=VALUE,支持 # 注释、成对引号、export 前缀;只
scripts/svc.sh <name> config [file|--clear] 更换/移除/查看(见 help lifecycle);
切换前 schema 校验(agent-system config explain -config <file>),运行中自动重启
即时生效;登记于 svc.env conffile=,info/status 可见。
EOF
return 0 ;;
recovery)
cat >&2 <<'EOF'
【recovery】自恢复:autostart 期望态 + recover + watchdog
期望运行态(autostart 键,svc.env):
- start / restart / deploy / update 成功启动(健康检查通过)→ autostart=1
- 显式 stop(含 deploy/update 前置停机)→ 移除 autostart(保持停止是明确意图)
- 进程自行死亡(崩溃、OOM 被杀、机器重启后)→ 键保留,等待恢复
- rm 注销后无 svc.env,recover 不再触碰
scripts/svc.sh recover [name] [--quiet]
扫描已登记服务(postgres 优先于 agent/static,保证绑定实例先起),
对 autostart=1 且 pidfile+cmdline 校验已死的服务执行裸 start
(复用持久化 exec= 与数据库绑定);每次尝试写 recover ok/fail 事件。
逐服务持 .start.lock(非阻塞,被 deploy/手动 start 持有时跳过本轮)。
进程反复即退时自然限频为约 1 次/分钟(健康探测 20s + timer 周期)。
scripts/svc.sh watchdog install 安装 systemd 用户级 timer(每分钟 recover):
~/.config/systemd/user/agent-system-svc-recover.{service,timer}
(OnBootSec=1min + OnUnitActiveSec=60s;开机自启依赖
loginctl enable-linger,未开时安装会提示)
scripts/svc.sh watchdog remove 停用并移除 timer
scripts/svc.sh watchdog status timer 状态 + autostart=1 的服务清单
无 systemd 用户会话的环境可退化为 cron:
* * * * * <ROOT>/scripts/svc.sh recover --quiet >/dev/null 2>&1
语义边界:
- recover 只用既有登记拉起,不重建产物、不改变任何配置;
- deploy/update 失败(构建失败、预检拒绝)→ 服务保持停止等人工介入,
watchdog 不盲目复活(前置停机已清 autostart);
- 端口被外部进程占用时 start 报错放弃(不抢不杀,watchdog 每轮重试无害)。
EOF
return 0 ;;
examples)
@@ -555,7 +607,7 @@ CLI_SEED_OWNER=""
CLI_FROM=""; CLI_PICK=""; CLI_DRYRUN=0
CLI_KIND=""; CLI_DIR=""; CLI_PURGE=0; CLI_REVEAL=0
CLI_APPLY=0; CLI_FORCE=0; CLI_ONLINE=0; CLI_FILE=""; CLI_OUT=""
CLI_CHECK=0; CLI_ALL=0; CLI_NOFETCH=0
CLI_CHECK=0; CLI_ALL=0; CLI_NOFETCH=0; CLI_QUIET=0
CLI_DB_USER=""; CLI_DB_PASSWORD=""; CLI_DB_NAME=""; CLI_DB_AUTH=""
CLI_TAGS=()
@@ -616,7 +668,7 @@ addr_compose() {
}
# ---- 名称与端口 ----
RESERVED_ENVS=" status list add set db rm create dsn commits history update deploy stop start restart logs help info version "
RESERVED_ENVS=" status list add set db rm create dsn commits history update deploy stop start restart logs help info version recover watchdog "
# valid_env NAME:服务名合法性(小写字母开头、a-z0-9-、≤16 位;保留字不可用)。
valid_env() {
@@ -642,10 +694,16 @@ ports_recorded() {
return 1
}
# port_listening PORT:端口是否被监听。优先 ss;不可用或无命中时退化为 bash 内建
# /dev/tcp 连接探测(CI 最小容器无 iproute2 时端口检查会失明——2026-10-11 实测:
# thief 占口,recover 未识别照常启动,健康探测打到 thief 上误报已启动)。
port_listening() {
local p="$1"
command -v ss >/dev/null 2>&1 || return 1
ss -ltn 2>/dev/null | awk -v p=":$p" '$4 ~ p"$" {f=1} END {exit !f}'
if command -v ss >/dev/null 2>&1; then
ss -ltn 2>/dev/null | awk -v p=":$p" '$4 ~ p"$" {f=1} END {exit !f}' && return 0
fi
(exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null && return 0
return 1
}
# derive_port NAME:由服务名确定性派生端口(8800+hash%300),冲突时在 8800..9099 递增。
@@ -799,6 +857,36 @@ port_owner() {
fi
}
# ---- 生命周期互斥锁与期望运行态(autostart;见 help recovery)----
# svc_lock MODE:对当前服务取 .start.lock 互斥锁。MODE=wait 阻塞(最长 300s),
# MODE=-n 非阻塞(抢不到返回 1)。flock 缺失时降级为无锁直通(尽力而为)。
# 锁随进程退出自动释放(fd 关闭);长驻循环(recover)用 svc_unlock 逐服务显式释放。
SVC_LOCK_HELD=0
svc_lock() {
[ "$SVC_LOCK_HELD" = 1 ] && return 0
command -v flock >/dev/null 2>&1 || { SVC_LOCK_HELD=1; return 0; }
mkdir -p "$ENV_DIR" 2>/dev/null || return 1
exec 9>>"$ENV_DIR/.start.lock" || return 1
if [ "$1" = "-n" ]; then
flock -n 9 || { exec 9>&-; return 1; }
else
flock -w 300 9 || c_warn "[$ENV_NAME] 生命周期锁等待超时(继续执行,注意并发)"
fi
SVC_LOCK_HELD=1
return 0
}
svc_unlock() {
[ "$SVC_LOCK_HELD" = 1 ] || return 0
exec 9>&- || true
SVC_LOCK_HELD=0
return 0
}
# svc_mark_autostart / svc_clear_autostart:期望运行态登记(成功 start 置 1,
# 显式 stop 移除;进程自行死亡不动——recover 据此判断是否拉回)。
svc_mark_autostart() { svc_set_key autostart 1 >/dev/null 2>&1 || true; }
svc_clear_autostart() { svc_del_key autostart 2>/dev/null || true; }
check_port_free() {
local owner; owner="$(port_owner)"
if [ -n "$owner" ]; then
@@ -806,6 +894,13 @@ check_port_free() {
c_err "处理:停掉该进程,或换端口(--port / --addr)"
return 1
fi
# ss 不可用时 port_owner 为空——再经 port_listening(含 /dev/tcp 兜底)确认,
# 否则无 iproute2 的环境里启动守卫完全失明(CI 容器实测)。
if port_listening "${ENV_ADDR##*:}"; then
c_err "[$ENV_NAME] 端口 ${ENV_ADDR##*:} 正被监听(未能识别占用者;无 ss 时仅能探测不能归因)"
c_err "处理:停掉该进程,或换端口(--port / --addr)"
return 1
fi
}
# ---- 服务配置解析(CLI > 配置文件/环境变量 > svc.env 持久化 > 默认)----
@@ -1771,10 +1866,12 @@ agent_do_start() {
fi
guard_env+=("AGENT_SYSTEM_DSN=$DB_DSN")
fi
# 9>&-:显式关闭锁 fd——服务进程绝不能继承 .start.lock(长驻持有会令后续
# 生命周期操作在锁上自锁死等;2026-10-11 自恢复任务实测发现)。
if [ "${#guard_env[@]}" -gt 0 ];then
nohup env "${guard_env[@]}" "$ENV_BIN" serve $exec_args >> "$ENV_DIR/svc.log" 2>&1 &
nohup env "${guard_env[@]}" "$ENV_BIN" serve $exec_args >> "$ENV_DIR/svc.log" 2>&1 9>&- &
else
nohup "$ENV_BIN" serve $exec_args >> "$ENV_DIR/svc.log" 2>&1 &
nohup "$ENV_BIN" serve $exec_args >> "$ENV_DIR/svc.log" 2>&1 9>&- &
fi
echo $! > "$ENV_PID"
# 健康探测:取 exec 里的 --addr(复用持久化配置时可能与当前派生值不同)。
@@ -1789,6 +1886,7 @@ agent_do_start() {
# 健康通过才写 svc.env——启动失败不再用新参数覆盖上次的可用配置
#(2026-10-05 dev 事故:失败后坏 exec= 持久化,裸 start 复用导致二次失败)。
record_state
svc_mark_autostart
c_ok "[$ENV_NAME] 已启动 pid=$(read_pid) → http://$phost:${probe##*:}(来源 ${SVC_SOURCE:-?},日志:$ENV_DIR/svc.log)"
hist_event start ok "$(read_pid)" "http://$phost:${probe##*:}"
# rdm 组合产物无 user/apikey CLI 子命令:设置播种(--model-*/--tavily-keys)仅
@@ -1808,6 +1906,7 @@ agent_do_start() {
done
# 进程存活但未响应:按实际运行参数登记(进程确以新参数在跑,登记与现实一致)。
record_state
svc_mark_autostart
c_err "[$ENV_NAME] 20s 健康检查未通过(进程存活但未响应),查看日志:tail -40 $ENV_DIR/svc.log"
hist_event start fail "$(read_pid)" "健康检查超时"
return 1
@@ -1922,13 +2021,14 @@ static_do_start() {
SVC_PIDCHECK="--directory $htdocs"
SVC_VERSION=""
c_info "[$ENV_NAME] 启动静态服务:python3 http.server $port --bind $host --directory $htdocs"
nohup python3 -m http.server "$port" --bind "$host" --directory "$htdocs" >> "$ENV_DIR/svc.log" 2>&1 &
nohup python3 -m http.server "$port" --bind "$host" --directory "$htdocs" >> "$ENV_DIR/svc.log" 2>&1 9>&- &
echo $! > "$ENV_PID"
local probe_host="$host"
[ "$probe_host" = "0.0.0.0" ] || [ "$probe_host" = "::" ] && probe_host="127.0.0.1"
for _ in $(seq 1 50); do
if curl -s -o /dev/null -m 2 "http://$probe_host:$port/" 2>/dev/null; then
record_state "htdocs=$htdocs" # 健康通过才写 svc.env(与 agent 路径同规)
svc_mark_autostart
c_ok "[$ENV_NAME] 已启动 pid=$(read_pid) → http://$probe_host:$port/(目录 $htdocs)"
hist_event start ok "$(read_pid)" "http://$probe_host:$port/"
return 0
@@ -1941,6 +2041,7 @@ static_do_start() {
sleep 0.2
done
record_state "htdocs=$htdocs"
svc_mark_autostart
c_err "[$ENV_NAME] 10s 健康检查未通过"
hist_event start fail "$(read_pid)" "健康检查超时"
return 1
@@ -2059,7 +2160,7 @@ pg_do_start() {
[ "$listen" = "0.0.0.0" ] || [ "$listen" = "::" ] && listen="'*'"
c_info "[$ENV_NAME] 启动 postgres:$pgdata(addr $ENV_ADDR)"
if ! "$pgbin/pg_ctl" -D "$pgdata" -w -t 30 -l "$ENV_DIR/pg.log" \
-o "-p ${ENV_ADDR##*:} -k $ENV_DIR -c listen_addresses=$listen" start >/dev/null 2>&1; then
-o "-p ${ENV_ADDR##*:} -k $ENV_DIR -c listen_addresses=$listen" start >/dev/null 2>&1 9>&-; then
c_err "[$ENV_NAME] pg_ctl start 失败,查看日志:tail -40 $ENV_DIR/pg.log"
hist_event start fail "" "pg_ctl start 失败"
return 1
@@ -2082,6 +2183,7 @@ pg_do_start() {
fi
record_state "user=$(svc_get user)" "password=$(svc_get password)" "dbname=$(svc_get dbname)" \
"authmode=$(svc_get authmode)" "pgdata=$pgdata"
svc_mark_autostart
if "$pgbin/pg_isready" -h "${ENV_ADDR%:*}" -p "${ENV_ADDR##*:}" >/dev/null 2>&1; then
c_ok "[$ENV_NAME] postgres 就绪 pid=$(read_pid) → $ENV_ADDR(库 $(svc_get dbname),用户 $(svc_get user))"
hist_event start ok "$(read_pid)" "pg_isready ok"
@@ -2100,11 +2202,13 @@ pg_do_stop() {
if [ -z "$pid" ] || ! pid_alive "$pid"; then
c_info "[$ENV_NAME] postgres 未在运行"
rm -f "$ENV_PID"
svc_clear_autostart
return 0
fi
c_info "[$ENV_NAME] 停止 postgres pid=$pid(pg_ctl -m fast,优雅回写)"
if [ -n "$pgdata" ] && "$pgbin/pg_ctl" -D "$pgdata" -m fast -w -t 30 stop >/dev/null 2>&1; then
rm -f "$ENV_PID"
svc_clear_autostart
c_ok "[$ENV_NAME] postgres 已停止"
hist_event stop ok "$pid" "pg_ctl fast"
return 0
@@ -2112,7 +2216,7 @@ pg_do_stop() {
c_warn "[$ENV_NAME] pg_ctl stop 失败,尝试 SIGTERM(绝不 SIGKILL postmaster)"
kill -TERM "$pid" 2>/dev/null || true
for _ in $(seq 1 100); do
pid_alive "$pid" || { rm -f "$ENV_PID"; c_ok "[$ENV_NAME] postgres 已停止(TERM)"; hist_event stop ok "$pid" "SIGTERM 兜底"; return 0; }
pid_alive "$pid" || { rm -f "$ENV_PID"; svc_clear_autostart; c_ok "[$ENV_NAME] postgres 已停止(TERM)"; hist_event stop ok "$pid" "SIGTERM 兜底"; return 0; }
sleep 0.1
done
c_err "[$ENV_NAME] postgres 未能停止(保留现场;查 $pgdata/postmaster.pid 与 $ENV_DIR/pg.log)"
@@ -2838,6 +2942,7 @@ do_stop() {
if ! pid_alive "$pid"; then
c_warn "[$ENV_NAME] pidfile 指向的进程已不存在(pid=$pid),清理 pidfile"
rm -f "$ENV_PID"
svc_clear_autostart # 显式 stop 对已死进程 = 期望保持停止
return 0
fi
case "$SVC_KIND" in
@@ -2846,6 +2951,7 @@ do_stop() {
c_info "[$ENV_NAME] 停止旧服务 pid=$pid(SIGTERM,等待优雅停机)"
proc_stop "$pid"
rm -f "$ENV_PID"
svc_clear_autostart
c_ok "[$ENV_NAME] 已停止"
hist_event stop ok "$pid" "SIGTERM"
;;
@@ -2853,12 +2959,14 @@ do_stop() {
return 0
}
# do_stop_quiet:deploy 前置——未运行时静默返回。
# do_stop_quiet:deploy 前置——未运行时静默返回(同时清除期望态:
# deploy 尾端的 start 成功会重新置位;失败则保持停止等人工介入)。
do_stop_quiet() {
local pid; pid="$(read_pid)"
if [ -z "$pid" ]; then return 0; fi
if [ -z "$pid" ]; then svc_clear_autostart; return 0; fi
if ! pid_alive "$pid"; then
rm -f "$ENV_PID"
svc_clear_autostart
return 0
fi
case "$SVC_KIND" in
@@ -2867,6 +2975,7 @@ do_stop_quiet() {
c_info "[$ENV_NAME] 停止旧服务 pid=$pid(SIGTERM,等待优雅停机)"
proc_stop "$pid"
rm -f "$ENV_PID"
svc_clear_autostart
c_ok "[$ENV_NAME] 已停止"
hist_event stop ok "$pid" "SIGTERM"
;;
@@ -3347,6 +3456,7 @@ cmd_update() {
case "$1" in
--check) CLI_CHECK=1; shift ;;
--all) CLI_ALL=1; shift ;;
--quiet) CLI_QUIET=1; shift ;;
--no-fetch) CLI_NOFETCH=1; shift ;;
--) shift; break ;;
-h|--help) usage; exit 0 ;;
@@ -3519,6 +3629,7 @@ cmd_update() {
c_info "[$name] 更新:$from → $tov($deploy_from)"
hist_event update begin "$(read_pid)" "from=$from to=$tov"
CLI_FROM="$deploy_from"
svc_lock wait
if agent_do_deploy; then
hist_event update ok "$(read_pid)" "from=$from to=$tov"
c_ok "[$name] 已更新:$from → $tov"
@@ -3528,12 +3639,200 @@ cmd_update() {
c_err "[$name] 更新失败($from → $tov),其余服务继续"
failed=$((failed + 1))
fi
svc_unlock
CLI_FROM=""
done
c_info "更新汇总:成功 $updated / 失败 $failed / 跳过 $skipped / 其余最新或不适用"
[ "$failed" -eq 0 ] && return 0 || return 1
}
# ---- 自恢复:recover 扫描拉回 + watchdog(systemd 用户 timer)----
# recover_targets [NAME...]:postgres 在前(绑定的 agent 依赖实例先起),其余按登记序。
recover_targets() {
local n
while IFS= read -r n; do
[ "$(svc_kind "$n")" = postgres ] && printf '%s\n' "$n"
done < <(registered_names)
while IFS= read -r n; do
[ "$(svc_kind "$n")" != postgres ] && printf '%s\n' "$n"
done < <(registered_names)
}
# cmd_recover [NAME...]:把 autostart=1 但已死的服务重新拉起(裸 start 复用持久化
# exec= 与数据库绑定;逐服务非阻塞持锁,被 deploy/手动 start 持有时跳过本轮)。
# 退出码:0 = 全部成功或无需动作;1 = 有恢复尝试失败(端口被占/启动失败等)。
cmd_recover() {
local names=() a
while [ $# -gt 0 ]; do
case "$1" in
--quiet) CLI_QUIET=1; shift ;;
--) shift; break ;;
-h|--help) cat >&2 <<'EOF'
用法:scripts/svc.sh recover [name...] [--quiet]
自恢复:扫描已登记服务(缺省全部;postgres 优先),对 svc.env autostart=1
且进程已死(pidfile+cmdline 校验)的服务执行裸 start 拉回。
通常由 watchdog timer 每分钟调用;手动执行用于立即恢复。
EOF
return 0 ;;
-*) c_err "recover 未知参数:$1(可用:--quiet)"; return 2 ;;
*) names+=("$1"); shift ;;
esac
done
local targets=() n rc=0 recovered=0 failed=0 skipped=0
if [ "${#names[@]}" -gt 0 ]; then
for a in ${names[@]+"${names[@]}"}; do
valid_env "$a" || { c_err "非法服务名:$a"; return 2; }
[ -f "$RUNTIME/$a/svc.env" ] || { c_err "[$a] 未登记(scripts/svc.sh list 查看)"; return 2; }
done
# 点名时保持用户顺序,但 postgres 仍前置(依赖顺序)。
local pgs=() others=()
for a in ${names[@]+"${names[@]}"}; do
[ "$(svc_kind "$a")" = postgres ] && pgs+=("$a") || others+=("$a")
done
targets=(${pgs[@]+"${pgs[@]}"} ${others[@]+"${others[@]}"})
else
while IFS= read -r n; do targets+=("$n"); done < <(recover_targets)
fi
[ "${#targets[@]}" -gt 0 ] || { c_info "暂无已登记服务(db create / deploy 会登记)"; return 0; }
for n in ${targets[@]+"${targets[@]}"}; do
[ "$(env_kv_of "$n" autostart)" = "1" ] || { skipped=$((skipped + 1)); continue; }
local st; st="$(svc_pid_state "$n")"
if [ "${st%% *}" = alive ]; then skipped=$((skipped + 1)); continue; fi
env_config "$n"
if ! svc_lock -n; then
c_warn "[$n] 生命周期锁被占用(deploy/手动操作进行中),本轮跳过"
skipped=$((skipped + 1)); continue
fi
c_info "[$n] recover:autostart=1 且进程已死,重新拉起(复用持久化配置)"
if do_start; then
hist_event recover ok "$(read_pid)" "autostart revival"
recovered=$((recovered + 1))
else
hist_event recover fail "" "autostart revival 失败(见 start fail 事件与 svc.log)"
failed=$((failed + 1)); rc=1
fi
svc_unlock
done
if [ "$recovered" -gt 0 ] || [ "$failed" -gt 0 ]; then
c_event "recover 汇总:拉回 $recovered / 失败 $failed / 无需动作 $skipped"
fi
return "$rc"
}
# ---- watchdog:systemd 用户级 timer,每分钟执行 recover ----
WD_UNIT_BASE="agent-system-svc-recover"
WD_UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
# wd_service_text ROOT / wd_timer_text:单元文件内容(纯函数,治理测试直接断言)。
wd_service_text() {
printf '%s
' \
"# 由 scripts/svc.sh watchdog install 生成(ROOT=$(printf '%s' "$1" | sed 's/[[:space:]]/\\ /g'));修改请重装或手工维护" \
"[Unit]" \
"Description=agent-system svc auto-recovery (restart crashed/killed services)" \
"" \
"[Service]" \
"Type=oneshot" \
"ExecStart=$1/scripts/svc.sh recover --quiet" \
"Nice=5"
}
wd_timer_text() {
printf '%s\n' \
"[Unit]" \
"Description=Run agent-system svc recovery every minute" \
"" \
"[Timer]" \
"OnBootSec=1min" \
"OnUnitActiveSec=60s" \
"AccuracySec=10s" \
"Unit=${WD_UNIT_BASE}.service" \
"" \
"[Install]" \
"WantedBy=timers.target"
}
# wd_systemd_ok:systemd 用户会话是否可用(不可用环境给出 cron 降级指引)。
wd_systemd_ok() {
command -v systemctl >/dev/null 2>&1 || return 1
systemctl --user show-environment >/dev/null 2>&1
}
# cmd_watchdog install|remove|status [--print]:看护 timer 的安装/移除/状态。
cmd_watchdog() {
local sub="${1:-status}"; [ $# -gt 0 ] && shift || true
case "$sub" in
install)
local print=0
case "${1:-}" in --print) print=1 ;; "") ;; *) c_err "watchdog install 未知参数:$1(可用:--print)"; return 2 ;; esac
local svc="$WD_UNIT_DIR/${WD_UNIT_BASE}.service" tmr="$WD_UNIT_DIR/${WD_UNIT_BASE}.timer"
if [ "$print" = 1 ]; then
c_info "watchdog install --print:只打印计划,不写文件、不碰 systemd"
printf '── %s ──\n%s\n' "$svc" "$(wd_service_text "$ROOT")"
printf '── %s ──\n%s\n' "$tmr" "$(wd_timer_text)"
wd_systemd_ok || c_warn "当前环境 systemd 用户会话不可用——正式 install 需要它;或退化用 cron(help recovery)"
return 0
fi
wd_systemd_ok || {
c_err "systemd 用户会话不可用(systemctl --user 失败)——无法安装 timer"
c_err " 降级方案(cron,每分钟):"
c_err " (crontab -l 2>/dev/null; echo '* * * * * $ROOT/scripts/svc.sh recover --quiet >/dev/null 2>&1') | crontab -"
return 1
}
mkdir -p "$WD_UNIT_DIR"
{ wd_service_text "$ROOT"; printf '\n'; } > "$svc"
{ wd_timer_text; printf '\n'; } > "$tmr"
systemctl --user daemon-reload || { c_err "daemon-reload 失败"; return 1; }
systemctl --user enable --now "${WD_UNIT_BASE}.timer" >/dev/null 2>&1 || {
c_err "timer enable 失败(systemctl --user status ${WD_UNIT_BASE}.timer 查原因)"; return 1; }
c_ok "watchdog timer 已安装并启用:${WD_UNIT_BASE}.timer(每分钟 recover;remove 移除)"
[ "$(loginctl show-user "$(id -un)" -p Linger 2>/dev/null)" = "Linger=yes" ] \
|| c_warn "用户未开 linger(sudo loginctl enable-linger $(id -un))——开机后 timer 不自启,登出不受影响"
c_info "立即自检:systemctl --user list-timers ${WD_UNIT_BASE}.timer;人工触发:systemctl --user start ${WD_UNIT_BASE}.service"
return 0
;;
remove|uninstall)
wd_systemd_ok || { c_info "systemd 用户会话不可用——只清理单元文件"; }
if wd_systemd_ok; then
systemctl --user disable --now "${WD_UNIT_BASE}.timer" >/dev/null 2>&1 || true
systemctl --user reset-failed "${WD_UNIT_BASE}.service" >/dev/null 2>&1 || true
fi
rm -f "$WD_UNIT_DIR/${WD_UNIT_BASE}.service" "$WD_UNIT_DIR/${WD_UNIT_BASE}.timer"
if wd_systemd_ok; then systemctl --user daemon-reload || true; fi
c_ok "watchdog 已移除(单元文件与 timer;服务登记不受影响)"
return 0
;;
status)
local n auto_list=""
if wd_systemd_ok; then
systemctl --user list-timers --no-pager "${WD_UNIT_BASE}.timer" 2>/dev/null | sed 's/^/ /' >&2 || true
local exec_line
exec_line="$(sed -n 's/^ExecStart=//p' "$WD_UNIT_DIR/${WD_UNIT_BASE}.service" 2>/dev/null || true)"
if [ -n "$exec_line" ] && [ "$exec_line" != "$ROOT/scripts/svc.sh recover --quiet" ]; then
c_warn "已安装 timer 指向其他检出:$exec_line(当前 ROOT=$ROOT;重装:watchdog install)"
fi
else
c_warn "systemd 用户会话不可用——timer 未安装(watchdog install 或用 cron,见 help recovery)"
fi
while IFS= read -r n; do
[ "$(env_kv_of "$n" autostart)" = "1" ] && auto_list="${auto_list:+$auto_list }$n"
done < <(registered_names)
c_info "autostart=1 的服务:${auto_list:-无(start 成功后自动登记)}"
return 0
;;
-h|--help|help)
cat >&2 <<'EOF'
用法:scripts/svc.sh watchdog install|remove|status [install --print]
install 写入并启用 systemd 用户 timer(每分钟 svc.sh recover --quiet)
install --print 只打印将写入的单元文件与计划(不落盘)
remove 停用并删除 timer(服务登记与运行状态不受影响)
status timer 状态 + autostart=1 的服务清单
EOF
return 0 ;;
*) c_err "未知子命令:$sub(可用:install|remove|status)"; return 2 ;;
esac
}
# ---- main ----
main() {
@@ -3576,6 +3875,8 @@ main() {
commits) shift; cmd_commits "$@"; exit 0 ;;
history) shift; cmd_history "$@"; exit 0 ;;
update) shift; cmd_update "$@" || exit $?; exit 0 ;;
recover) shift; cmd_recover "$@" || exit $?; exit 0 ;;
watchdog) shift; cmd_watchdog "$@" || exit $?; exit 0 ;;
esac
ENV_NAME="$1"; shift
# 动作词可省略(省略 = deploy);flags 可直接跟随服务名。
@@ -3601,6 +3902,11 @@ main() {
exit 2
fi
env_config "$ENV_NAME"
# 生命周期动作持 .start.lock(wait 模式;进程退出自动释放)——deploy 全程含
# 构建窗口,防 recover/并发 start 在停机-换产物间隙抢跑。只读动作不加锁。
case "$action" in
deploy|stop|start|restart) svc_lock wait || { c_err "[$ENV_NAME] 无法获取生命周期锁"; exit 1; } ;;
esac
case "$action" in
deploy)
case "$SVC_KIND" in
+387
View File
@@ -0,0 +1,387 @@
"""svc.sh 自恢复回归:autostart 期望态 + recover + watchdog(2026-10-11)。
验证崩溃(SIGKILL,模拟 OOM 被杀)后 recover 按持久化登记拉回服务、显式 stop
不被复活、并发生命周期锁防抢跑、watchdog 单元生成正确。全部使用临时目录 +
桩二进制(bash + python http.server 模拟 agent-system;HTTP 子进程随父进程
退出——真实产物是单进程二进制,被杀即端口释放,桩须对齐该语义)。
"""
import fcntl
import os
import shutil
import socket
import subprocess
import tempfile
import time
import unittest
from pathlib import Path
SOURCE = Path(__file__).resolve().parents[1]
SVC_SCRIPT = SOURCE / "scripts" / "svc.sh"
# 桩 agent-system:version / config explain(环境层 DSN 感知)/ serve(记录参数后
# 用 http.server 应答健康探测)。TERM 转发给子进程保证 stop 干净;bash 自身不
# exec——pidcheck(二进制路径)须留在 cmdline。HTTP 子进程轮询 ppid:父进程被
# SIGKILL 后 0.5s 内自行退出(对齐单进程产物“进程死=端口释放”的语义)。
STUB = r"""#!/usr/bin/env bash
set -u
trace_dir="${SVC_STUB_TRACE:-$PWD/.svc-stub-trace}"
mkdir -p "$trace_dir"
case "${1:-}" in
version)
echo "stub-agent-system 1.0"
exit 0 ;;
config)
if [ "${2:-}" = "explain" ]; then
echo "config file: (none)"
echo "field value source"
if [ -n "${AGENT_SYSTEM_DSN:-}" ]; then
printf 'dsn postgresql://%s env\n' "$AGENT_SYSTEM_DSN"
else
echo "dsn - default"
fi
fi
exit 0 ;;
serve)
shift
case "${1:-}" in
-h|--help)
echo "Usage: agent-system serve -addr H:P -data DIR -plugins DIR -auth MODE -config FILE"
exit 0 ;;
esac
printf 'args: %s\n' "$*" > "$trace_dir/serve.txt"
addr=""
while [ $# -gt 0 ]; do
case "$1" in --addr) addr="$2"; shift 2 ;; *) shift ;; esac
done
port="${addr##*:}"; host="${addr%:*}"
trap 'kill "$child" 2>/dev/null; exit 0' TERM INT
python3 - "$port" "$host" <<'PYSRV' >/dev/null 2>&1 &
import os, sys, threading, time
from http.server import BaseHTTPRequestHandler, HTTPServer
port, host = int(sys.argv[1]), sys.argv[2]
class H(BaseHTTPRequestHandler):
def _ok(self, body=b'{"ok":true}'):
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
if self.command != 'HEAD':
self.wfile.write(body)
def do_GET(self):
self._ok()
def do_HEAD(self):
self._ok(b'')
def do_PUT(self):
n = int(self.headers.get('Content-Length') or 0)
self.rfile.read(n)
self._ok()
def log_message(self, *a):
pass
ppid = os.getppid()
def watch():
while True:
try:
if os.getppid() != ppid:
os._exit(0)
except Exception:
os._exit(0)
time.sleep(0.3)
threading.Thread(target=watch, daemon=True).start()
try:
HTTPServer((host, port), H).serve_forever()
except Exception:
os._exit(1)
PYSRV
child=$!
wait "$child"
;;
*)
exit 0 ;;
esac
"""
class SvcAutoRecoverTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.mkdtemp(prefix="svc-autorecover-")
self.addCleanup(shutil.rmtree, tmp, True)
self.root = Path(tmp)
(self.root / "scripts").mkdir(parents=True)
shutil.copy2(SVC_SCRIPT, self.root / "scripts" / "svc.sh")
self.trace = self.root / "trace"
self.stub = self.root / "stub-agent-system"
self.stub.write_text(STUB)
self.stub.chmod(0o755)
(self.root / "plugins").mkdir()
self.env = os.environ.copy()
self.env.update({
"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null",
"GIT_TERMINAL_PROMPT": "0",
"GIT_AUTHOR_NAME": "svc recover", "GIT_COMMITTER_NAME": "svc recover",
"GIT_AUTHOR_EMAIL": "svc-test@example.invalid",
"GIT_COMMITTER_EMAIL": "svc-test@example.invalid",
"SVC_STUB_TRACE": str(self.trace),
"SVC_SRC_DIR": str(self.root / "srccache"),
# 隔离 watchdog 单元目录(--print 不落盘,但断言要在受控目录下做)。
"XDG_CONFIG_HOME": str(self.root / "xdg"),
})
for key in list(self.env):
if key.startswith("SVC_") and key not in ("SVC_STUB_TRACE", "SVC_SRC_DIR"):
self.env.pop(key)
for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"):
self.env.pop(key, None)
def tearDown(self):
# 兜底清理:停掉本测试遗留的服务进程,避免端口跨用例泄漏。
runtime = self.root / "runtime"
if runtime.is_dir():
for d in sorted(runtime.iterdir()):
if (d / "svc.pid").exists():
try:
subprocess.run(
["bash", str(self.root / "scripts" / "svc.sh"), d.name, "stop"],
cwd=self.root, env=self.env, timeout=30,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except subprocess.TimeoutExpired:
pass # 兜底强杀(见下)
# 直接 SIGKILL 兜底:确保桩进程(及其锁 fd/端口)不跨用例泄漏。
try:
pidf = d / "svc.pid"
if pidf.exists():
pid = int(pidf.read_text().strip() or 0)
if pid:
os.kill(pid, 9)
except (ValueError, ProcessLookupError, PermissionError, FileNotFoundError):
pass
# ---- 夹具 ----
def svc(self, *args, check=True):
proc = subprocess.run(
["bash", str(self.root / "scripts" / "svc.sh"), *map(str, args)],
cwd=self.root, env=self.env, text=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=120)
if check:
self.assertEqual(proc.returncode, 0, proc.stdout)
return proc
def runtime(self, name):
return self.root / "runtime" / name
def kv(self, name, key):
envfile = self.runtime(name) / "svc.env"
if not envfile.exists():
return None
for line in envfile.read_text().splitlines():
if line.startswith(key + "="):
return line[len(key) + 1:]
return None
def seed_agent(self, name, port):
d = self.runtime(name)
(d / "bin").mkdir(parents=True, exist_ok=True)
(d / "data").mkdir(exist_ok=True)
(d / "plugins").mkdir(exist_ok=True)
shutil.copy2(self.stub, d / "bin" / "agent-system")
(d / "svc.env").write_text("".join("%s=%s\n" % kv for kv in [
("kind", "agent"), ("env", name), ("pid", ""),
("addr", "127.0.0.1:%d" % port), ("tags", ""),
("source", "local@deadbee"), ("version", "stub"), ("started", ""),
("pidcheck", str(d / "bin" / "agent-system")),
("exec", "--addr 127.0.0.1:%d --data %s --plugins %s"
% (port, d / "data", d / "plugins")),
]))
def start_ok(self, name):
result = self.svc(name, "start")
self.assertIn("已启动", result.stdout)
return result
def pid_of(self, name):
pidf = self.runtime(name) / "svc.pid"
if not pidf.exists():
return None
text = pidf.read_text().strip()
return int(text) if text else None
def pid_alive(self, pid):
if not pid:
return False
try:
os.kill(pid, 0)
except (ProcessLookupError, PermissionError):
return False
# SIGKILL 后的孤儿在 CI 容器里可能成僵尸(PID 1 不收割)——僵尸按死处理,
# 与 svc.sh pid_alive_mark 的口径一致(僵尸 cmdline 为空 = 不匹配 = 已死)。
try:
state = Path("/proc/%d/stat" % pid).read_text().rsplit(")", 1)[1].split()[0]
if state == "Z":
return False
except (OSError, IndexError):
pass
return True
def kill9(self, name):
"""SIGKILL 服务主进程(模拟 OOM killer),保留 pidfile 与登记。"""
pid = self.pid_of(name)
self.assertTrue(self.pid_alive(pid), "服务应先在运行")
os.kill(pid, 9)
for _ in range(100): # 等 bash 主进程消失(HTTP 子进程由 ppid 看护自杀)
if not self.pid_alive(pid):
break
time.sleep(0.05)
self.assertFalse(self.pid_alive(pid))
time.sleep(0.6)
return pid
def history_events(self, name, event):
f = self.runtime(name) / "history.jsonl"
if not f.exists():
return []
out = []
for line in f.read_text().splitlines():
if ('"event":"%s"' % event) in line:
out.append(line)
return out
# ---- 1. 期望态:start 成功置位 / 显式 stop 清除(含进程已死的 stop)----
def test_start_records_autostart_and_stop_clears(self):
self.seed_agent("dev", 18801)
self.start_ok("dev")
self.assertEqual(self.kv("dev", "autostart"), "1")
self.svc("dev", "stop")
self.assertIsNone(self.kv("dev", "autostart"))
def test_stop_on_dead_process_clears_autostart(self):
self.seed_agent("dev", 18802)
self.start_ok("dev")
self.kill9("dev")
# 进程已死但 autostart 保留(等待 recover);显式 stop 表示"保持停止"。
self.assertEqual(self.kv("dev", "autostart"), "1")
self.svc("dev", "stop")
self.assertIsNone(self.kv("dev", "autostart"))
# ---- 2. recover:崩溃拉回 / 运行中不动 / 显式停止不复活 / 未启动不碰 ----
def test_recover_restarts_sigkilled_service(self):
self.seed_agent("dev", 18803)
self.start_ok("dev")
old_pid = self.kill9("dev")
result = self.svc("recover", "--quiet")
self.assertEqual(result.returncode, 0, result.stdout)
self.assertIn("recover 汇总:拉回 1", result.stdout) # c_info 在 --quiet 下仍输出
new_pid = self.pid_of("dev")
self.assertTrue(self.pid_alive(new_pid))
self.assertNotEqual(new_pid, old_pid)
self.assertEqual(self.kv("dev", "autostart"), "1")
ok_events = self.history_events("dev", "recover")
self.assertTrue(any('"result":"ok"' in l for l in ok_events), ok_events)
# 恢复用的仍是持久化 exec=(svc.env 登记未漂移)。
self.assertIn("--addr 127.0.0.1:18803", self.kv("dev", "exec"))
def test_recover_noop_while_running(self):
self.seed_agent("dev", 18804)
self.start_ok("dev")
pid = self.pid_of("dev")
result = self.svc("recover")
self.assertEqual(result.returncode, 0, result.stdout)
self.assertEqual(self.pid_of("dev"), pid) # 未重启
self.assertEqual(self.history_events("dev", "recover"), [])
def test_recover_respects_explicit_stop(self):
self.seed_agent("dev", 18805)
self.start_ok("dev")
self.svc("dev", "stop")
result = self.svc("recover")
self.assertEqual(result.returncode, 0, result.stdout)
self.assertFalse(self.pid_alive(self.pid_of("dev")))
self.assertEqual(self.history_events("dev", "recover"), [])
def test_recover_ignores_never_started_service(self):
self.seed_agent("dev", 18806) # 从未 start:无 autostart 键
result = self.svc("recover")
self.assertEqual(result.returncode, 0, result.stdout)
self.assertFalse(self.pid_alive(self.pid_of("dev")))
self.assertEqual(self.history_events("dev", "recover"), [])
# ---- 3. 并发防护:生命周期锁被持有时 recover 跳过 ----
def test_recover_skips_when_lock_held(self):
self.seed_agent("dev", 18807)
self.start_ok("dev")
self.kill9("dev")
lock = self.runtime("dev") / ".start.lock"
with open(lock, "w") as fh:
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
result = self.svc("recover")
self.assertEqual(result.returncode, 0, result.stdout)
self.assertIn("锁被占用", result.stdout)
self.assertFalse(self.pid_alive(self.pid_of("dev")))
self.assertEqual(self.history_events("dev", "recover"), [])
fcntl.flock(fh, fcntl.LOCK_UN)
# 锁释放后同一命令即可拉回。
result = self.svc("recover")
self.assertEqual(result.returncode, 0, result.stdout)
self.assertTrue(self.pid_alive(self.pid_of("dev")))
# ---- 4. 恢复失败路径:端口被外部进程占用 → fail 事件 + 非零退出 ----
def test_recover_fails_when_port_stolen(self):
self.seed_agent("dev", 18808)
self.start_ok("dev")
self.kill9("dev")
thief = subprocess.Popen(
["python3", "-m", "http.server", "18808", "--bind", "127.0.0.1"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
self.addCleanup(thief.terminate)
# 等 thief 真正完成 bind 再 recover——慢环境里 Popen 返回时端口尚未监听,
# recover 会抢先起服务,把“端口被抢”测成竞态(CI 首轮实测)。
deadline = time.monotonic() + 5.0
bound = False
while time.monotonic() < deadline:
s = socket.socket()
s.settimeout(0.2)
try:
s.connect(("127.0.0.1", 18808))
bound = True
except OSError:
pass
finally:
s.close()
if bound:
break
time.sleep(0.05)
self.assertTrue(bound, "thief 未能在 5s 内监听 18808")
try:
result = self.svc("recover", check=False)
self.assertEqual(result.returncode, 1, result.stdout)
fail_events = self.history_events("dev", "recover")
self.assertTrue(any('"result":"fail"' in l for l in fail_events), fail_events)
self.assertFalse(self.pid_alive(self.pid_of("dev"))) # 未强抢端口
finally:
thief.terminate()
thief.wait(timeout=10)
# ---- 5. watchdog:单元文件生成(--print 走 main 分发全链路)----
def test_watchdog_print_plan(self):
result = self.svc("watchdog", "install", "--print")
self.assertIn(
"ExecStart=%s/scripts/svc.sh recover --quiet" % self.root, result.stdout)
self.assertIn("OnUnitActiveSec=60s", result.stdout)
self.assertIn("OnBootSec=1min", result.stdout)
self.assertIn("WantedBy=timers.target", result.stdout)
# 只打印,不落盘(受控 XDG_CONFIG_HOME 下无单元文件)。
unit_dir = self.root / "xdg" / "systemd" / "user"
self.assertFalse((unit_dir / "agent-system-svc-recover.timer").exists())
self.assertFalse((unit_dir / "agent-system-svc-recover.service").exists())
def test_recover_reserved_name_rejected(self):
self.seed_agent("recover", 18809) # 保留字不可作服务名
result = self.svc("recover", "recover", check=False)
self.assertEqual(result.returncode, 2, result.stdout)
if __name__ == "__main__":
unittest.main()