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:
+305
-12
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user