Add svc auto-recovery: autostart desired-state, recover, watchdog

Why: svc services are bare nohup'd processes with persisted registration
but no supervision -- a crash, an OOM kill, or a reboot left them dead
until manual restart (2026-10-11 outage: dev service lost, runtime/ also
wiped by hand, nothing brought services back).

Evidence: tests/test_svc_recover.py 10 cases green (start records
autostart, stop clears it incl. dead-process stop, SIGKILL then recover
revives with persisted exec=, lock-held skips, port-stolen fails with
recover fail event, watchdog unit text via --print); make test-governance
146/146 OK locally; real-machine watchdog install + kill -9 revival to be
recorded on the task card.

Impact: svc.sh gains autostart key in svc.env, `recover [name]
[--quiet]` (postgres-first, per-service .start.lock, recover history
events), `watchdog install|remove|status` (systemd user timer, cron
fallback hint), lifecycle lock across start/stop/restart/deploy/update;
spawned services no longer inherit the lock fd (9>&-), and lock release
no longer clobbers script stderr. Makefile adds svc-recover/svc-watchdog;
docs/svc.md documents semantics and boundaries.

Refs: task card .task/feature-svc-autorecover.md; docs/svc.md 6.5/6.6;
parallel planning card feature/service-restart-recovery-plan (same
problem space, plan-stage).
This commit is contained in:
2026-10-11 20:20:27 +00:00
parent 552b28e307
commit eda942523c
4 changed files with 734 additions and 15 deletions
+305 -12
View File
@@ -23,6 +23,9 @@
# scripts/svc.sh commits [dev|release] [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)════════════════════
@@ -69,6 +72,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 使用环境内产物
#
@@ -91,6 +95,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"
#
@@ -121,8 +127,11 @@ SVC_ENVFILE=""
SRC_DIR="${SVC_SRC_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/agent-system-svc/src}"
# 提示信息走 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; }
@@ -150,6 +159,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
@@ -157,7 +168,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:-}"
@@ -196,6 +207,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)
@@ -210,6 +222,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;未命中本机实例时使用)
@@ -220,6 +234,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(文件与大小)
@@ -308,6 +323,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 更新所有"过时且运行中"的服务
@@ -435,6 +454,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)
@@ -539,7 +591,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=()
@@ -600,7 +652,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() {
@@ -783,6 +835,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
@@ -1641,10 +1723,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(复用持久化配置时可能与当前派生值不同)。
@@ -1659,6 +1743,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##*:}"
seed_settings "$phost" "${probe##*:}"
@@ -1673,6 +1758,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
@@ -1781,13 +1867,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
@@ -1800,6 +1887,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
@@ -1918,7 +2006,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
@@ -1941,6 +2029,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"
@@ -1959,11 +2048,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
@@ -1971,7 +2062,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)"
@@ -2697,6 +2788,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
@@ -2705,6 +2797,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"
;;
@@ -2712,12 +2805,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
@@ -2726,6 +2821,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"
;;
@@ -3206,6 +3302,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 ;;
@@ -3350,6 +3447,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"
@@ -3359,12 +3457,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() {
@@ -3407,6 +3693,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 可直接跟随服务名。
@@ -3432,6 +3720,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