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:
@@ -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
@@ -426,14 +426,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. 命令参考:数据库
|
||||
|
||||
+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
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
"""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 subprocess
|
||||
import tempfile
|
||||
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)
|
||||
return True
|
||||
except (ProcessLookupError, PermissionError):
|
||||
return False
|
||||
|
||||
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
|
||||
import time
|
||||
time.sleep(0.05)
|
||||
self.assertFalse(self.pid_alive(pid))
|
||||
import time
|
||||
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)
|
||||
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()
|
||||
Reference in New Issue
Block a user