Merge authoritative main into the task branch
Why: resolve modules/documents gitlink conflict for Dev PR #451. Evidence: Documents feature tip ebf655c merges main@1048e7c with r1.1/U1 plan. Impact: pin documents to ebf655c; PR should become mergeable after Docs #84 lands. Refs: Documents #84; Dev #451 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+11
-6
@@ -336,9 +336,12 @@ Releases 仓跑 `scripts/build-release.sh <v>`。
|
||||
无声明式插件资产;管理页「插件管理」列表只反映目录插件,组合插件经
|
||||
`/api/registry` 与命令面可见;
|
||||
- **web/ 工作台**:RDM 自带零构建静态工作台(直连后端 API),deploy 时复制到
|
||||
`runtime/<name>/htdocs/`,用伴生 static 服务伺服
|
||||
(`scripts/svc.sh <name>web deploy --kind static --dir runtime/<name>/htdocs`;
|
||||
注意该目录随本环境 `rm --purge` 一并删除);
|
||||
`runtime/<name>/htdocs/`,并**自动**部署伴生 static 服务
|
||||
(`scripts/svc.sh <name>web deploy --kind static --dir runtime/<name>/htdocs`);
|
||||
伴生失败或 `GET /` 非 200 时 deploy 记 `ui-missing` 并以非 ok 结束。
|
||||
注意 `htdocs/` 随本环境 `rm --purge` 一并删除;
|
||||
- **换源门禁**:同名服务切换源种类(如 `dev@*`→`rdm@*`)须加
|
||||
`--allow-source-switch`;同种类换 sha(含 `update`)不需要;
|
||||
- **启动兼容**:组合二进制容忍基座风格的首位 `serve` 子命令并提供 `version`
|
||||
子命令;svc 的 `version`/`serve -h` 探测带 timeout 护栏;
|
||||
- **不支持** `--mock` 与 `--config`(组合 CLI 无此旗标);模型设置播种
|
||||
@@ -359,11 +362,13 @@ Releases 仓跑 `scripts/build-release.sh <v>`。
|
||||
3. **候选(禁止双写)**:先 `scripts/svc.sh <name> stop`,再
|
||||
`scripts/svc.sh <cand> deploy --from rdm@<sha> --data runtime/<name>/data --port <空闲> --auth on`;
|
||||
验登录 / `/api/projects` / `rdmAuditReady` 后停候选。
|
||||
4. **Cutover**:`scripts/svc.sh <name> deploy --from rdm@<sha>`(同名 redeploy 保留
|
||||
data/auth)。失败回滚:`scripts/svc.sh <name> deploy --from dev@<旧源>`。
|
||||
4. **Cutover**:若当前为非 rdm 源,加 `--allow-source-switch`:
|
||||
`scripts/svc.sh <name> deploy --from rdm@<sha> --allow-source-switch`
|
||||
(同名 redeploy 保留 data/auth;伴生 `<name>web` 自动就位)。失败回滚:
|
||||
`scripts/svc.sh <name> deploy --from dev@<旧源> --allow-source-switch`。
|
||||
5. **基座 FE 验收**:`PLAYWRIGHT_API_TARGET=http://127.0.0.1:<port> npm run preview`
|
||||
(或 Vite dev 代理);`svc` static **无** `/api` 反代。RDM `web/` 工作台是另一套 UI
|
||||
(`htdocs` + 伴生 static),与 React SPA 门控并行可用。
|
||||
(`htdocs` + 自动伴生 static),与 React SPA 门控并行可用。
|
||||
|
||||
### 5.5 辅助
|
||||
|
||||
|
||||
+1
-1
Submodule modules/backend updated: 5e8ada727c...bbe977351d
+1
-1
Submodule modules/documents updated: 9e4ee27c7e...ebf655c4d7
+1
-1
Submodule modules/frontend updated: cc233fec90...c4ebcf5404
+280
-18
@@ -298,15 +298,19 @@ EOF
|
||||
pins/ 子模块后 make build(纯 Go,无需 npm),产物
|
||||
bin/agent-system-rdm 复制为环境二进制;插件目录按空集安装
|
||||
(组合插件编译进二进制),web/ 工作台静态资产随环境落
|
||||
htdocs/(伴生 static 服务用 --dir 指向该目录)。
|
||||
htdocs/,并自动部署伴生 <name>web(--kind static --dir)。
|
||||
注意:rdm 源不支持 --mock 与 --config;模型设置播种
|
||||
(--model-*/--tavily-keys)仅基座产物支持。
|
||||
--pick [dev|release|rdm] 交互式编号选择(需 TTY;列表与提示走 stderr)
|
||||
--dry-run 只打印部署计划不执行
|
||||
--allow-source-switch 同名服务换源种类(dev↔rdm↔release↔local)须显式确认;
|
||||
同种类换 sha(含 update)不需要
|
||||
|
||||
行为细节:
|
||||
- deploy 前自动停止旧实例(只停自己 pidfile+cmdline 校验的进程)
|
||||
- agent 启动后等健康检查(20s),随后按 --model-*/--tavily-keys 播种设置(幂等)
|
||||
- 部署收尾按源种类做 UI 探活:dev/local/release 要求本服务 GET /=200;
|
||||
rdm 要求伴生 <name>web GET /=200(失败记 ui-missing,deploy 非 ok)
|
||||
- 旧版本产物不认识 --auth/--config 时自动省略(探测 serve 用法)并提示;
|
||||
不消费环境变量 DSN(AGENT_SYSTEM_DSN)的旧产物在需要交付数据库配置时
|
||||
拒绝部署/启动(不静默按内嵌 SQLite 运行)
|
||||
@@ -317,6 +321,7 @@ EOF
|
||||
示例:
|
||||
scripts/svc.sh prod deploy --from release@0.1.0 --tag stable
|
||||
scripts/svc.sh prod deploy --from dev@73b2802 --dry-run
|
||||
scripts/svc.sh dev deploy --from rdm@310c3fe --allow-source-switch
|
||||
scripts/svc.sh ui-r4 deploy --kind static --from dev@73b2802
|
||||
scripts/svc.sh ui-qa deploy --kind static --dir ../frontend/dist
|
||||
EOF
|
||||
@@ -629,6 +634,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_ALLOW_SOURCE_SWITCH=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=()
|
||||
@@ -665,6 +671,7 @@ parse_args() {
|
||||
--reveal) CLI_REVEAL=1; shift ;;
|
||||
--apply) CLI_APPLY=1; shift ;;
|
||||
--force) CLI_FORCE=1; shift ;;
|
||||
--allow-source-switch) CLI_ALLOW_SOURCE_SWITCH=1; shift ;;
|
||||
--online) CLI_ONLINE=1; shift ;;
|
||||
--file) need_value "$@"; CLI_FILE="$2"; shift 2 ;;
|
||||
--out) need_value "$@"; CLI_OUT="$2"; shift 2 ;;
|
||||
@@ -860,10 +867,13 @@ pid_alive_mark() { # PID MARKER:cmdline 含 MARKER 视为我们的进程
|
||||
case "$cmd" in *"$mark"*) return 0 ;; *) return 1 ;; esac
|
||||
}
|
||||
|
||||
# pid_alive PID:当前服务的 pid 校验(svc.env pidcheck 优先;旧登记回退 agent-system)。
|
||||
# pid_alive PID:当前服务的 pid 校验(内存 SVC_PIDCHECK → svc.env pidcheck → agent-system)。
|
||||
# 启动路径在 record_state 之前就会调本函数;static 首启若只读 svc.env 会误回退到
|
||||
# agent-system,把 python http.server 判成「进程即退」。
|
||||
pid_alive() {
|
||||
local pid="$1" mark
|
||||
mark="$(svc_get pidcheck)"
|
||||
mark="${SVC_PIDCHECK:-}"
|
||||
[ -z "$mark" ] && mark="$(svc_get pidcheck)"
|
||||
[ -z "$mark" ] && mark="agent-system"
|
||||
pid_alive_mark "$pid" "$mark"
|
||||
}
|
||||
@@ -890,6 +900,81 @@ port_owner() {
|
||||
fi
|
||||
}
|
||||
|
||||
# port_owner_pid:从 ss 行解析占口 PID(无 ss / 无 pid= 字段则空失败)。
|
||||
port_owner_pid() {
|
||||
local line pid
|
||||
line="$(port_owner)"
|
||||
[ -n "$line" ] || return 1
|
||||
pid="$(printf '%s' "$line" | sed -n 's/.*pid=\([0-9][0-9]*\).*/\1/p' | head -1)"
|
||||
[ -n "$pid" ] || return 1
|
||||
printf '%s' "$pid"
|
||||
}
|
||||
|
||||
# cmdline_matches_own PID:进程 cmdline 是否含本服务标记(pidcheck/ENV_BIN;
|
||||
# 路径被 cp 覆盖后 argv 仍常保留原路径;basename + ENV_DIR 作收紧兜底)。
|
||||
cmdline_matches_own() {
|
||||
local pid="$1" mark base cmd
|
||||
mark="${SVC_PIDCHECK:-}"
|
||||
[ -z "$mark" ] && mark="$(svc_get pidcheck)"
|
||||
[ -z "$mark" ] && mark="${ENV_BIN:-}"
|
||||
[ -n "$mark" ] || return 1
|
||||
[ -r "/proc/$pid/cmdline" ] || return 1
|
||||
cmd="$( { tr '\0' ' ' < "/proc/$pid/cmdline"; } 2>/dev/null )" || return 1
|
||||
case "$cmd" in *"$mark"*) return 0 ;; esac
|
||||
base="$(basename "$mark" 2>/dev/null || true)"
|
||||
[ -n "$base" ] && [ "$base" != "$mark" ] || return 1
|
||||
case "$cmd" in
|
||||
*"$base"*)
|
||||
[ -n "${ENV_DIR:-}" ] || return 1
|
||||
case "$cmd" in *"$ENV_DIR"*) return 0 ;; esac
|
||||
;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# find_own_in_ancestry PID:从占口 PID 沿 PPid 向上找本服务进程(stdout 打印命中 PID)。
|
||||
# 真实 agent-system 单进程自听;桩用 bash+python http.server 时 ss 看到的是子进程。
|
||||
find_own_in_ancestry() {
|
||||
local cur="$1" i=0 next
|
||||
while [ -n "$cur" ] && [ "$cur" -gt 1 ] 2>/dev/null && [ "$i" -lt 8 ]; do
|
||||
if cmdline_matches_own "$cur"; then
|
||||
printf '%s' "$cur"
|
||||
return 0
|
||||
fi
|
||||
next="$(awk '/^PPid:/ {print $2; exit}' "/proc/$cur/status" 2>/dev/null || true)"
|
||||
[ -n "$next" ] && [ "$next" != "$cur" ] || return 1
|
||||
cur="$next"
|
||||
i=$((i + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# reclaim_own_port:若 ENV_ADDR 端口被本服务残留占用则停掉并清 pidfile。
|
||||
# 返回 0=端口已空闲(本就空或回收成功);1=仍被外部/未知占用或回收未释放端口。
|
||||
# 修复:update/deploy 误清 pidfile 后 cp 覆盖二进制留下 (deleted) 孤儿,
|
||||
# check_port_free 一律报「非本服务」导致无法重启(2026-10-12 genui@8973)。
|
||||
reclaim_own_port() {
|
||||
local port="${ENV_ADDR##*:}" listener target i
|
||||
[ -n "$port" ] || return 0
|
||||
port_listening "$port" || return 0
|
||||
listener="$(port_owner_pid 2>/dev/null || true)"
|
||||
[ -n "$listener" ] || return 1
|
||||
target="$(find_own_in_ancestry "$listener" 2>/dev/null || true)"
|
||||
if [ -n "$target" ]; then
|
||||
c_warn "[$ENV_NAME] 端口 $port 被本服务残留进程占用(listener=$listener svc=$target),正在回收"
|
||||
proc_stop "$target"
|
||||
rm -f "$ENV_PID"
|
||||
hist_event stop ok "$target" "reclaim_own_port"
|
||||
for i in $(seq 1 50); do
|
||||
port_listening "$port" || return 0
|
||||
sleep 0.1
|
||||
done
|
||||
c_err "[$ENV_NAME] 本服务残留已发终止信号,但端口 $port 仍被占用"
|
||||
return 1
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---- 生命周期互斥锁与期望运行态(autostart;见 help recovery)----
|
||||
# svc_lock MODE:对当前服务取 .start.lock 互斥锁。MODE=wait 阻塞(最长 300s),
|
||||
# MODE=-n 非阻塞(抢不到返回 1)。flock 缺失或等待超时 → 拒绝(不得直通冲突操作)。
|
||||
@@ -1054,9 +1139,19 @@ svc_mark_autostart() { manifest_ok && python3 "$MANIFEST_PY" "$ENV_DIR" set-de
|
||||
svc_clear_autostart() { manifest_ok && python3 "$MANIFEST_PY" "$ENV_DIR" set-desired stopped >/dev/null 2>&1 && return 0; svc_del_key autostart 2>/dev/null || true; }
|
||||
|
||||
check_port_free() {
|
||||
local owner; owner="$(port_owner)"
|
||||
# 先尝试回收本服务残留(无/坏 pidfile 的孤儿);外部占用仍拒绝。
|
||||
reclaim_own_port || true
|
||||
local owner pid own
|
||||
owner="$(port_owner)"
|
||||
if [ -n "$owner" ]; then
|
||||
c_err "[$ENV_NAME] 端口被占用(非本服务):$owner"
|
||||
pid="$(port_owner_pid 2>/dev/null || true)"
|
||||
own=""
|
||||
[ -n "$pid" ] && own="$(find_own_in_ancestry "$pid" 2>/dev/null || true)"
|
||||
if [ -n "$own" ]; then
|
||||
c_err "[$ENV_NAME] 端口被本服务残留占用且回收失败:$owner"
|
||||
else
|
||||
c_err "[$ENV_NAME] 端口被占用(非本服务):$owner"
|
||||
fi
|
||||
c_err "处理:停掉该进程,或换端口(--port / --addr)"
|
||||
return 1
|
||||
fi
|
||||
@@ -1083,8 +1178,12 @@ env_config() {
|
||||
ENV_LOG="$ENV_DIR/svc.log"
|
||||
ENV_BIN_DIR="$ENV_DIR/bin"
|
||||
ENV_BIN="$ENV_BIN_DIR/agent-system"
|
||||
# 数据库配置解析态按实例重置——批量 update/restart 逐服务解析时不串用上一服务的 DB_*。
|
||||
# 按实例重置内存态——批量 update/restart 逐服务解析时不串用上一服务。
|
||||
# DB_*:防跨服务串库;SVC_PIDCHECK/EXEC/VERSION/SOURCE:防 pid_alive 与
|
||||
# reclaim_own_port 用上一服务标记误判本服务进程为「已死/非本服务」(2026-10-12
|
||||
# update --all:dev 成功后 genui/rdm1/rdmcand 端口仍被孤儿占用)。
|
||||
SVC_DB_MODE=""; SVC_DB_SOURCE=""; SVC_DB_REF=""; SVC_DB_DSN_SAVE=""
|
||||
SVC_PIDCHECK=""; SVC_EXEC=""; SVC_VERSION=""; SVC_SOURCE=""
|
||||
local registered=""
|
||||
[ -f "$ENV_DIR/svc.env" ] && registered="$(svc_kind "$name")"
|
||||
SVC_KIND="${registered:-agent}"
|
||||
@@ -1400,6 +1499,11 @@ resolve_source() { # → SRC_KINDSRC(local|dev|release) + SRC_LABEL(+SRC_FULL/
|
||||
|
||||
install_artifacts() { # SRCBIN SRCPLUGINS:agent 产物复制进服务目录。
|
||||
local srcbin="$1" srcplug="$2"
|
||||
# 覆盖前必须无本服务占口进程——否则留下 exe=(deleted) 孤儿,后续 start 误报非本服务。
|
||||
if ! reclaim_own_port; then
|
||||
c_err "[$ENV_NAME] 安装前端口仍被占用,拒绝覆盖二进制(避免 exe deleted 孤儿)"
|
||||
return 1
|
||||
fi
|
||||
mkdir -p "$ENV_BIN_DIR"
|
||||
rm -rf "$ENV_DIR/plugins"
|
||||
cp -f "$srcbin" "$ENV_BIN" || return 1
|
||||
@@ -1555,8 +1659,124 @@ agent_do_user() {
|
||||
|
||||
# ---- agent:部署与启动 ----
|
||||
|
||||
# source_kind_prefix LABEL:取 source= 的种类前缀(dev@sha → dev;空 → 空)。
|
||||
source_kind_prefix() {
|
||||
case "$1" in
|
||||
*@*) printf '%s' "${1%%@*}" ;;
|
||||
*) printf '%s' "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# require_allow_source_switch:同名 agent 换源种类(dev↔rdm↔release↔local)须显式确认;
|
||||
# 同种类换 sha(含 update)不要求。与 service kind 冲突门禁正交。
|
||||
require_allow_source_switch() {
|
||||
local prev prev_kind new_kind
|
||||
prev="$(svc_get source)"
|
||||
[ -n "$prev" ] || return 0
|
||||
prev_kind="$(source_kind_prefix "$prev")"
|
||||
new_kind="$(source_kind_prefix "$SRC_LABEL")"
|
||||
[ -n "$prev_kind" ] && [ -n "$new_kind" ] || return 0
|
||||
[ "$prev_kind" = "$new_kind" ] && return 0
|
||||
if [ "${CLI_ALLOW_SOURCE_SWITCH:-0}" = 1 ]; then
|
||||
c_warn "[$ENV_NAME] 换源已确认:$prev → $SRC_LABEL(--allow-source-switch)"
|
||||
return 0
|
||||
fi
|
||||
c_err "[$ENV_NAME] 换源种类 $prev_kind → $new_kind($prev → $SRC_LABEL)需显式确认"
|
||||
c_err " 加 --allow-source-switch 后重试(会改变环境是否自带 UI)"
|
||||
return 1
|
||||
}
|
||||
|
||||
rdm_companion_name() { printf '%sweb' "$1"; }
|
||||
|
||||
# http_code URL:返回 HTTP 状态码(连接失败 → 000)。
|
||||
http_code() {
|
||||
curl -s -o /dev/null -m 2 -w '%{http_code}' "$1" 2>/dev/null || printf '000'
|
||||
}
|
||||
|
||||
# rdm_workbench_missing NAME:rdm@* agent 且伴生未登记或未运行 → 0(真)。
|
||||
rdm_workbench_missing() {
|
||||
local name="$1" src cname st
|
||||
src="$(env_kv_of "$name" source)"
|
||||
case "$src" in rdm@*) ;; *) return 1 ;; esac
|
||||
cname="$(rdm_companion_name "$name")"
|
||||
[ -f "$RUNTIME/$cname/svc.env" ] || return 0
|
||||
st="$(svc_pid_state "$cname")"
|
||||
[ "${st%% *}" = alive ] && return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
# deploy_rdm_companion_ui:子进程部署 ${ENV_NAME}web --kind static --dir htdocs。
|
||||
deploy_rdm_companion_ui() {
|
||||
local cname="$1" htdocs="$2"
|
||||
if ! valid_env "$cname"; then
|
||||
c_err "[$ENV_NAME] 伴生名非法:$cname(服务名 ≤16 字符;ui-missing)"
|
||||
return 1
|
||||
fi
|
||||
if [ ! -d "$htdocs" ] || [ ! -f "$htdocs/index.html" ]; then
|
||||
c_err "[$ENV_NAME] RDM 工作台缺失:$htdocs(ui-missing)"
|
||||
return 1
|
||||
fi
|
||||
c_info "[$ENV_NAME] 自动部署伴生工作台:$cname --kind static --dir $htdocs"
|
||||
if ! "$0" "$cname" deploy --kind static --dir "$htdocs"; then
|
||||
c_err "[$ENV_NAME] 伴生工作台部署失败(ui-missing)"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# assert_ui_ready:按源种类断言页面入口 HTTP 200(与进程探活分层)。
|
||||
# rdm@* → 伴生 ${name}web;dev@*/local@*/release@* → agent 自身 GET /。
|
||||
assert_ui_ready() {
|
||||
local src="${SRC_LABEL:-$(svc_get source)}"
|
||||
local code url host port addr cname probe
|
||||
case "$src" in
|
||||
rdm@*)
|
||||
cname="$(rdm_companion_name "$ENV_NAME")"
|
||||
addr="$(env_kv_of "$cname" addr)"
|
||||
if [ -z "$addr" ]; then
|
||||
c_err "[$ENV_NAME] UI 探活失败:伴生 $cname 未登记(ui-missing)"
|
||||
return 1
|
||||
fi
|
||||
host="${addr%:*}"; port="${addr##*:}"
|
||||
[ "$host" = "0.0.0.0" ] || [ "$host" = "::" ] && host="127.0.0.1"
|
||||
url="http://$host:$port/"
|
||||
code="$(http_code "$url")"
|
||||
if [ "$code" != 200 ]; then
|
||||
c_err "[$ENV_NAME] UI 探活失败:伴生 $url → HTTP $code(期望 200;ui-missing)"
|
||||
return 1
|
||||
fi
|
||||
c_ok "[$ENV_NAME] UI 就绪:伴生 $cname → $url"
|
||||
return 0
|
||||
;;
|
||||
dev@*|local@*|release@*)
|
||||
probe="${ENV_ADDR}"
|
||||
case "$(svc_get exec)" in *--addr\ *)
|
||||
probe="$(printf '%s' "$(svc_get exec)" | sed -n 's/.*--addr \([^ ]*\).*/\1/p')" ;;
|
||||
esac
|
||||
[ -n "$probe" ] || probe="$ENV_ADDR"
|
||||
host="${probe%:*}"; port="${probe##*:}"
|
||||
[ "$host" = "0.0.0.0" ] || [ "$host" = "::" ] && host="127.0.0.1"
|
||||
url="http://$host:$port/"
|
||||
code="$(http_code "$url")"
|
||||
if [ "$code" != 200 ]; then
|
||||
c_err "[$ENV_NAME] UI 探活失败:$url → HTTP $code(期望 200;ui-missing)"
|
||||
return 1
|
||||
fi
|
||||
c_ok "[$ENV_NAME] UI 就绪:$url"
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
agent_do_deploy() {
|
||||
resolve_source
|
||||
if ! require_allow_source_switch; then
|
||||
hist_event deploy fail "$(read_pid)" "换源种类需 --allow-source-switch($SRC_LABEL)"
|
||||
return 1
|
||||
fi
|
||||
if [ "$CLI_DRYRUN" = 1 ]; then print_agent_plan; return 0; fi
|
||||
# 停旧之前的静态配置验证:数据库配置可解析(绑定有效/无冲突)、conffile 存在、
|
||||
# 现产物承接能力——配置性问题不制造停机窗口(登记保持可恢复,失败直接返回)。
|
||||
@@ -1566,7 +1786,7 @@ agent_do_deploy() {
|
||||
fi
|
||||
hist_event deploy begin "$(read_pid)" "from=$SRC_LABEL"
|
||||
do_stop_quiet
|
||||
local wt
|
||||
local wt RDM_UI_OK=0
|
||||
case "$SRC_KINDSRC" in
|
||||
local)
|
||||
c_info "[$ENV_NAME] 构建当前工作区(make build)——唯一写主检出构建产物的路径"
|
||||
@@ -1591,15 +1811,17 @@ agent_do_deploy() {
|
||||
mkdir -p "$wt/.svc-empty-plugins"
|
||||
install_artifacts "$wt/bin/agent-system-rdm" "$wt/.svc-empty-plugins" \
|
||||
|| { hist_event deploy fail "$(read_pid)" "产物复制失败"; return 1; }
|
||||
# RDM 自带零构建工作台(web/,直连后端 API 的静态资产)随环境落 htdocs/,
|
||||
# 供伴生 static 服务直接 --dir 使用(注意:随本环境 rm --purge 一并删除)。
|
||||
# RDM 自带零构建工作台(web/)落 htdocs/;收尾自动部署伴生 ${name}web。
|
||||
if [ -d "$wt/web" ]; then
|
||||
rm -rf "$ENV_DIR/htdocs"
|
||||
if cp -r "$wt/web" "$ENV_DIR/htdocs"; then
|
||||
c_ok "[$ENV_NAME] RDM 工作台就位:$ENV_DIR/htdocs(伴生服务:scripts/svc.sh ${ENV_NAME}web deploy --kind static --dir $ENV_DIR/htdocs)"
|
||||
RDM_UI_OK=1
|
||||
c_ok "[$ENV_NAME] RDM 工作台就位:$ENV_DIR/htdocs(将自动部署伴生 $(rdm_companion_name "$ENV_NAME"))"
|
||||
else
|
||||
c_warn "[$ENV_NAME] RDM web/ 工作台复制失败(不影响 API 服务启动)"
|
||||
c_warn "[$ENV_NAME] RDM web/ 工作台复制失败"
|
||||
fi
|
||||
else
|
||||
c_warn "[$ENV_NAME] RDM 源无 web/ 工作台目录"
|
||||
fi
|
||||
recycle_worktree "rdm-$SRC_SHA" "$RDM_SRC_DIR"
|
||||
;;
|
||||
@@ -1612,6 +1834,23 @@ agent_do_deploy() {
|
||||
return 1
|
||||
fi
|
||||
agent_do_start || { hist_event deploy fail "$(read_pid)" "启动失败($SRC_LABEL)"; return 1; }
|
||||
case "$SRC_KINDSRC" in
|
||||
rdm)
|
||||
if [ "$RDM_UI_OK" != 1 ]; then
|
||||
c_err "[$ENV_NAME] RDM 工作台未就位(ui-missing):缺少可伺服的 htdocs/"
|
||||
hist_event deploy fail "$(read_pid)" "ui-missing(htdocs 未就位)"
|
||||
return 1
|
||||
fi
|
||||
if ! deploy_rdm_companion_ui "$(rdm_companion_name "$ENV_NAME")" "$ENV_DIR/htdocs"; then
|
||||
hist_event deploy fail "$(read_pid)" "ui-missing(伴生部署失败)"
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
if ! assert_ui_ready; then
|
||||
hist_event deploy fail "$(read_pid)" "ui-missing"
|
||||
return 1
|
||||
fi
|
||||
hist_event deploy ok "$(read_pid)" "from=$SRC_LABEL"
|
||||
return 0
|
||||
}
|
||||
@@ -1636,7 +1875,8 @@ print_agent_plan() {
|
||||
printf ' 来源:%s(完整提交 %s;独立 RDM 源仓 %s)\n' "$SRC_LABEL" "$SRC_FULL" "$RDM_SRC_DIR"
|
||||
printf ' 物化:worktree %s(pins submodule update --init --recursive + make build)\n' "$RUNTIME/.worktrees/rdm-$SRC_SHA"
|
||||
printf ' 产物:%s ← bin/agent-system-rdm + 空插件目录(组合插件编译进二进制)\n' "$ENV_BIN"
|
||||
printf ' 工作台:web/ → %s/htdocs(伴生 static:--kind static --dir 该目录)\n' "$ENV_DIR" ;;
|
||||
printf ' 工作台:web/ → %s/htdocs;自动伴生:scripts/svc.sh %s deploy --kind static --dir 该目录\n' \
|
||||
"$ENV_DIR" "$(rdm_companion_name "$ENV_NAME")" ;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
@@ -2208,11 +2448,13 @@ resolve_dir_deploy() { # --dir 路径:登记(含 dry-run 计划)后交由
|
||||
}
|
||||
|
||||
# write_static_reg HTDOCS:登记/更新 static 服务(保留 pid 等当前态由 start 覆写)。
|
||||
# 同步写入 pidcheck,避免首启在 record_state 前 pid_alive 误回退到 agent-system。
|
||||
write_static_reg() {
|
||||
local htdocs="$1"
|
||||
local old_pid=""; [ -f "$ENV_PID" ] && old_pid="$(read_pid)"
|
||||
local merged_tags; merged_tags="$(svc_merge_tags)" # 先读旧文件再截断
|
||||
mkdir -p "$ENV_DIR"
|
||||
SVC_PIDCHECK="--directory $htdocs"
|
||||
{
|
||||
printf 'kind=static\n'
|
||||
printf 'env=%s\n' "$ENV_NAME"
|
||||
@@ -2222,6 +2464,7 @@ write_static_reg() {
|
||||
printf 'source=%s\n' "$SRC_LABEL"
|
||||
printf 'started=%s\n' "$(date -Is)"
|
||||
printf 'htdocs=%s\n' "$htdocs"
|
||||
printf 'pidcheck=%s\n' "$SVC_PIDCHECK"
|
||||
} > "$ENV_DIR/svc.env"
|
||||
chmod 600 "$ENV_DIR/svc.env"
|
||||
}
|
||||
@@ -2256,10 +2499,11 @@ static_do_start() {
|
||||
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 9>&- &
|
||||
echo $! > "$ENV_PID"
|
||||
local probe_host="$host"
|
||||
local probe_host="$host" code
|
||||
[ "$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
|
||||
code="$(http_code "http://$probe_host:$port/")"
|
||||
if [ "$code" = 200 ]; then
|
||||
record_state "htdocs=$htdocs" # 健康通过才写 svc.env(与 agent 路径同规)
|
||||
svc_mark_autostart
|
||||
c_ok "[$ENV_NAME] 已启动 pid=$(read_pid) → http://$probe_host:$port/(目录 $htdocs)"
|
||||
@@ -2275,7 +2519,7 @@ static_do_start() {
|
||||
done
|
||||
record_state "htdocs=$htdocs"
|
||||
svc_mark_autostart
|
||||
c_err "[$ENV_NAME] 10s 健康检查未通过"
|
||||
c_err "[$ENV_NAME] 10s 健康检查未通过(末次 HTTP ${code:-?})"
|
||||
hist_event start fail "$(read_pid)" "健康检查超时"
|
||||
return 1
|
||||
}
|
||||
@@ -3323,11 +3567,15 @@ do_stop() {
|
||||
local pid; pid="$(read_pid)"
|
||||
if [ -z "$pid" ]; then
|
||||
c_info "[$ENV_NAME] 无 pidfile——没有由本脚本启动的实例"
|
||||
# 仍回收本服务残留占口(update 误清 pidfile 后的孤儿)。
|
||||
reclaim_own_port || true
|
||||
svc_clear_autostart
|
||||
return 0
|
||||
fi
|
||||
if ! pid_alive "$pid"; then
|
||||
c_warn "[$ENV_NAME] pidfile 指向的进程已不存在(pid=$pid),清理 pidfile"
|
||||
rm -f "$ENV_PID"
|
||||
reclaim_own_port || true
|
||||
svc_clear_autostart # 显式 stop 对已死进程 = 期望保持停止
|
||||
return 0
|
||||
fi
|
||||
@@ -3349,9 +3597,14 @@ do_stop() {
|
||||
# deploy 尾端的 start 成功会重新置位;失败则保持停止等人工介入)。
|
||||
do_stop_quiet() {
|
||||
local pid; pid="$(read_pid)"
|
||||
if [ -z "$pid" ]; then svc_clear_autostart; return 0; fi
|
||||
if [ -z "$pid" ]; then
|
||||
reclaim_own_port || true
|
||||
svc_clear_autostart
|
||||
return 0
|
||||
fi
|
||||
if ! pid_alive "$pid"; then
|
||||
rm -f "$ENV_PID"
|
||||
reclaim_own_port || true
|
||||
svc_clear_autostart
|
||||
return 0
|
||||
fi
|
||||
@@ -3489,6 +3742,11 @@ global_list() {
|
||||
fi
|
||||
done
|
||||
[ "${#names[@]}" -eq 0 ] && c_info "(暂无已登记服务;db create / deploy 会登记)"
|
||||
for name in ${names[@]+"${names[@]}"}; do
|
||||
if rdm_workbench_missing "$name"; then
|
||||
c_warn " [$name] 无工作台入口(伴生 $(rdm_companion_name "$name") 未登记或未运行)"
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -3519,6 +3777,10 @@ env_status_detail() {
|
||||
printf ' 监听守卫 insecureListen=%s allowedHosts=%s\n' \
|
||||
"$(env_var "$ENV_NAME" INSECURE_LISTEN)" \
|
||||
"$(env_var "$ENV_NAME" ALLOWED_HOSTS)"
|
||||
if rdm_workbench_missing "$ENV_NAME"; then
|
||||
printf ' 工作台 无入口(伴生 %s 未登记或未运行;scripts/svc.sh %s deploy --kind static --dir %s/htdocs)\n' \
|
||||
"$(rdm_companion_name "$ENV_NAME")" "$(rdm_companion_name "$ENV_NAME")" "$ENV_DIR"
|
||||
fi
|
||||
;;
|
||||
postgres)
|
||||
printf ' 库 %s 用户 %s\n' "$(svc_get dbname)" "$(svc_get user)"
|
||||
@@ -4301,8 +4563,8 @@ main() {
|
||||
exit 0
|
||||
fi
|
||||
parse_args "$@"
|
||||
if { [ -n "$CLI_FROM" ] || [ -n "$CLI_PICK" ] || [ "$CLI_DRYRUN" = 1 ] || [ -n "$CLI_DIR" ]; } && [ "$action" != deploy ]; then
|
||||
c_err "--from/--pick/--dry-run/--dir 仅用于 deploy 动作"
|
||||
if { [ -n "$CLI_FROM" ] || [ -n "$CLI_PICK" ] || [ "$CLI_DRYRUN" = 1 ] || [ -n "$CLI_DIR" ] || [ "$CLI_ALLOW_SOURCE_SWITCH" = 1 ]; } && [ "$action" != deploy ]; then
|
||||
c_err "--from/--pick/--dry-run/--dir/--allow-source-switch 仅用于 deploy 动作"
|
||||
exit 2
|
||||
fi
|
||||
env_config "$ENV_NAME"
|
||||
|
||||
+163
-6
@@ -11,6 +11,8 @@ import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
SOURCE = Path(__file__).resolve().parents[1]
|
||||
@@ -60,8 +62,10 @@ case "${1:-}" in
|
||||
trap 'kill "$child" 2>/dev/null; exit 0' TERM INT
|
||||
# GET/HEAD 一律 200(健康探测语义与 http.server 等价);PUT /api/* 捕获头与
|
||||
# 请求体到 trace/put.txt 供播种断言(settings 播种验证,2026-10-11)。
|
||||
# HTTP 子进程轮询 ppid:父进程被 SIGKILL 后自行退出,避免与 recover 套件
|
||||
# 共用 1880x 端口时泄漏占用(与 test_svc_recover 桩同规)。
|
||||
python3 - "$port" "$host" "$trace_dir" <<'PYSRV' >/dev/null 2>&1 &
|
||||
import sys
|
||||
import os, sys, threading, time
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
port, host, trace = int(sys.argv[1]), sys.argv[2], sys.argv[3]
|
||||
class H(BaseHTTPRequestHandler):
|
||||
@@ -83,7 +87,20 @@ class H(BaseHTTPRequestHandler):
|
||||
self._ok()
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
HTTPServer((host, port), H).serve_forever()
|
||||
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"
|
||||
@@ -601,6 +618,56 @@ class SvcDbRecoveryTests(unittest.TestCase):
|
||||
self.svc("dev", "restart")
|
||||
self.assertIn("AGENT_SYSTEM_DSN=%s" % dsn, self.serve_record())
|
||||
|
||||
def test_batch_update_stops_second_service_despite_prior_pidcheck(self):
|
||||
"""批量 update 不得用上一服务的 SVC_PIDCHECK 误判下一服务进程。
|
||||
|
||||
复现 2026-10-12:update --all 先成功部署 a,内存 SVC_PIDCHECK 残留 a 的
|
||||
bin 路径;do_stop_quiet / reclaim_own_port 对 b 匹配失败 → 误清 pidfile、
|
||||
旧进程占口 → 报「非本服务」。env_config 须按实例清空 SVC_PIDCHECK 等。
|
||||
"""
|
||||
old_sha, new_sha = self.make_src_repo()
|
||||
ports = {"a": 18841, "b": 18842}
|
||||
old_pids = {}
|
||||
for name, port in ports.items():
|
||||
d = self.runtime(name)
|
||||
self.seed_agent(
|
||||
name, port,
|
||||
"--addr 127.0.0.1:%d --data %s --plugins %s --auth auto"
|
||||
% (port, d / "data", d / "plugins"),
|
||||
extra_rows=[("source", "dev@%s" % old_sha)])
|
||||
self.start_ok(name)
|
||||
pidf = d / "svc.pid"
|
||||
self.assertTrue(pidf.exists(), name)
|
||||
old_pids[name] = int(pidf.read_text().strip())
|
||||
self.assertTrue(self.pid_alive(name), name)
|
||||
|
||||
result = self.svc("update", "a", "b")
|
||||
self.assertNotIn("非本服务", result.stdout, result.stdout)
|
||||
self.assertNotIn("更新失败", result.stdout, result.stdout)
|
||||
for name in ("a", "b"):
|
||||
self.assertEqual(self.kv(name, "source"), "dev@%s" % new_sha, name)
|
||||
self.assertTrue(self.pid_alive(name), "%s 应在运行" % name)
|
||||
new_pid = int((self.runtime(name) / "svc.pid").read_text().strip())
|
||||
self.assertNotEqual(new_pid, old_pids[name],
|
||||
"%s 应已换新进程" % name)
|
||||
# 与 svc.sh / recover 夹具一致:僵尸按死处理(CI 容器 PID 1 不收割)。
|
||||
old_still = False
|
||||
try:
|
||||
os.kill(old_pids[name], 0)
|
||||
try:
|
||||
state = Path("/proc/%d/stat" % old_pids[name]).read_text() \
|
||||
.rsplit(")", 1)[1].split()[0]
|
||||
old_still = state != "Z"
|
||||
except (OSError, IndexError):
|
||||
old_still = True
|
||||
except (ProcessLookupError, PermissionError):
|
||||
old_still = False
|
||||
self.assertFalse(old_still, "%s 旧进程应已停止" % name)
|
||||
hist = (self.runtime(name) / "history.jsonl").read_text()
|
||||
self.assertIn('"event":"stop"', hist, hist)
|
||||
self.assertIn('"result":"ok"', hist)
|
||||
self.assertIn('"event":"update"', hist)
|
||||
|
||||
|
||||
# ---- 12. 播种认证(2026-10-11 修复):认证开启自动铸临时 key,用后回收 ----
|
||||
|
||||
@@ -671,10 +738,14 @@ class SvcDbRecoveryTests(unittest.TestCase):
|
||||
|
||||
# ---- 13. rdm 部署源(下游组合仓 Agent-System-RDM):物化/产物/工作台/播种跳过 ----
|
||||
|
||||
def make_rdm_repo(self):
|
||||
"""构造下游组合仓夹具:Makefile build 产出桩 agent-system-rdm + web/ 工作台。"""
|
||||
def make_rdm_repo(self, with_web=True):
|
||||
"""构造下游组合仓夹具:Makefile build 产出桩 agent-system-rdm + 可选 web/。"""
|
||||
remote = self.root / "rdmremote.git"
|
||||
work = self.root / "rdmwork"
|
||||
if remote.exists():
|
||||
shutil.rmtree(remote)
|
||||
if work.exists():
|
||||
shutil.rmtree(work)
|
||||
run = lambda cwd, *args: subprocess.run(
|
||||
["git", *args], cwd=cwd, env=self.env, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=60,
|
||||
@@ -683,8 +754,9 @@ class SvcDbRecoveryTests(unittest.TestCase):
|
||||
run(self.root, "init", "--initial-branch=main", str(work))
|
||||
(work / "Makefile").write_text(
|
||||
"build:\n\tmkdir -p bin\n\tcp %s bin/agent-system-rdm\n" % self.stub)
|
||||
(work / "web").mkdir()
|
||||
(work / "web" / "index.html").write_text("<html>rdm workbench</html>\n")
|
||||
if with_web:
|
||||
(work / "web").mkdir()
|
||||
(work / "web" / "index.html").write_text("<html>rdm workbench</html>\n")
|
||||
run(work, "add", ".")
|
||||
run(work, "commit", "-m", "rdm v1")
|
||||
run(work, "remote", "add", "origin", str(remote))
|
||||
@@ -702,6 +774,23 @@ class SvcDbRecoveryTests(unittest.TestCase):
|
||||
})
|
||||
return env
|
||||
|
||||
def http_get(self, url, timeout=2):
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=timeout) as resp:
|
||||
return resp.status, resp.read().decode("utf-8", "replace")
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, ""
|
||||
except OSError:
|
||||
return 0, ""
|
||||
|
||||
def companion_url(self, agent_name):
|
||||
addr = self.kv(agent_name + "web", "addr")
|
||||
self.assertIsNotNone(addr, "companion %sweb not registered" % agent_name)
|
||||
host, port = addr.rsplit(":", 1)
|
||||
if host in ("0.0.0.0", "::"):
|
||||
host = "127.0.0.1"
|
||||
return "http://%s:%s/" % (host, port)
|
||||
|
||||
def test_rdm_source_deploy_end_to_end(self):
|
||||
remote, work = self.make_rdm_repo()
|
||||
env = self.rdm_env(remote)
|
||||
@@ -715,10 +804,50 @@ class SvcDbRecoveryTests(unittest.TestCase):
|
||||
self.assertEqual(list((d / "plugins").iterdir()), [])
|
||||
self.assertIn("rdm workbench", (d / "htdocs" / "index.html").read_text())
|
||||
self.assertIn("args:", self.serve_record()) # 健康通过即登记启动参数
|
||||
# 伴生 static 自动部署并探活 200。
|
||||
self.assertEqual(self.kv("rdm1web", "kind"), "static")
|
||||
self.assertTrue(self.kv("rdm1web", "source").startswith("dir@"))
|
||||
code, body = self.http_get(self.companion_url("rdm1"))
|
||||
self.assertEqual(code, 200)
|
||||
self.assertIn("rdm workbench", body)
|
||||
self.assertIn("UI 就绪", result.stdout)
|
||||
# rdm 形态跳过设置播种(无 user/apikey 子命令):给了模型键也不产生 PUT/CLI。
|
||||
self.assertFalse((self.trace / "put.txt").exists())
|
||||
self.assertNotIn("user list", self.cli_record())
|
||||
|
||||
def test_rdm_companion_missing_web_fails_deploy(self):
|
||||
remote, work = self.make_rdm_repo(with_web=False)
|
||||
env = self.rdm_env(remote)
|
||||
result = self.svc("rdm2", "deploy", "--from", "rdm", "--port", "18843",
|
||||
env=env, check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("ui-missing", result.stdout)
|
||||
hist = (self.runtime("rdm2") / "history.jsonl").read_text()
|
||||
self.assertIn("ui-missing", hist)
|
||||
self.assertIsNone(self.kv("rdm2web", "kind"))
|
||||
|
||||
def test_source_switch_requires_flag(self):
|
||||
remote, work = self.make_rdm_repo()
|
||||
env = self.rdm_env(remote)
|
||||
# 先以 local 源部署(内嵌 UI 桩一律 200)。
|
||||
self.svc("sw1", "deploy", "--from", "local", "--port", "18844")
|
||||
old = self.kv("sw1", "source")
|
||||
self.assertTrue(old.startswith("local@"))
|
||||
# 无旗标换到 rdm → 拒绝,source 不变。
|
||||
denied = self.svc("sw1", "deploy", "--from", "rdm", "--port", "18844",
|
||||
env=env, check=False)
|
||||
self.assertNotEqual(denied.returncode, 0)
|
||||
self.assertIn("allow-source-switch", denied.stdout)
|
||||
self.assertEqual(self.kv("sw1", "source"), old)
|
||||
# 带旗标允许换源,伴生就位。
|
||||
ok = self.svc("sw1", "deploy", "--from", "rdm", "--port", "18844",
|
||||
"--allow-source-switch", env=env)
|
||||
self.assertTrue(self.kv("sw1", "source").startswith("rdm@"))
|
||||
self.assertIn("UI 就绪", ok.stdout)
|
||||
code, body = self.http_get(self.companion_url("sw1"))
|
||||
self.assertEqual(code, 200)
|
||||
self.assertIn("rdm workbench", body)
|
||||
|
||||
def test_rdm_source_commits_and_update_track(self):
|
||||
remote, work = self.make_rdm_repo()
|
||||
env = self.rdm_env(remote)
|
||||
@@ -740,6 +869,34 @@ class SvcDbRecoveryTests(unittest.TestCase):
|
||||
self.assertTrue(new.startswith("rdm@"))
|
||||
self.assertNotEqual(new, old)
|
||||
self.assertIn("args:", self.serve_record()) # 更新后健康在跑
|
||||
# 同轨 update 不需 --allow-source-switch;伴生仍 200。
|
||||
code, body = self.http_get(self.companion_url("rdm1"))
|
||||
self.assertEqual(code, 200)
|
||||
self.assertIn("rdm workbench", body)
|
||||
|
||||
# ---- static 全新部署:首启健康循环不得误判「进程即退」 ----
|
||||
|
||||
def test_static_fresh_deploy_registers_pidcheck_and_starts(self):
|
||||
"""全新 static --dir:登记须含 pidcheck;首启探活不得把 http.server 判死。"""
|
||||
htdocs = self.root / "static-site"
|
||||
htdocs.mkdir()
|
||||
(htdocs / "index.html").write_text("<html>static-ok</html>\n")
|
||||
port = 18951
|
||||
result = self.svc(
|
||||
"uiqa", "deploy", "--kind", "static",
|
||||
"--dir", str(htdocs), "--addr", "127.0.0.1:%d" % port)
|
||||
self.assertIn("已启动", result.stdout)
|
||||
self.assertNotIn("进程即退", result.stdout)
|
||||
self.assertEqual(self.kv("uiqa", "kind"), "static")
|
||||
pidcheck = self.kv("uiqa", "pidcheck")
|
||||
self.assertIsNotNone(pidcheck)
|
||||
self.assertIn("--directory", pidcheck)
|
||||
# write_static_reg 用 cd 后的绝对路径;与 resolve 后的 htdocs 对齐。
|
||||
self.assertIn(str(htdocs.resolve()), pidcheck)
|
||||
self.assertTrue(self.pid_alive("uiqa"))
|
||||
with urllib.request.urlopen("http://127.0.0.1:%d/" % port, timeout=3) as resp:
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertIn(b"static-ok", resp.read())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -218,7 +218,7 @@ class ResolveDbConfigFixtures(unittest.TestCase):
|
||||
|
||||
|
||||
class ResolverStateIsolation(unittest.TestCase):
|
||||
"""解析态按实例隔离(env_config 重置,防 update --all 串库)。"""
|
||||
"""解析态按实例隔离(env_config 重置,防 update --all 串库 / 串 pidcheck)。"""
|
||||
|
||||
def test_env_config_resets_resolver_state(self):
|
||||
# 棘轮:env_config 必须重置 SVC_DB_*(删除即回归「同进程连续启动跨服务
|
||||
@@ -228,6 +228,14 @@ class ResolverStateIsolation(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(out.strip(), "1")
|
||||
|
||||
def test_env_config_resets_pidcheck_runtime_state(self):
|
||||
# 棘轮:env_config 必须清空 SVC_PIDCHECK(删除即回归「批量 update 用上一
|
||||
# 服务 mark 误判本服务进程 → 误清 pidfile / 无法 reclaim」)。
|
||||
out = _bash(
|
||||
'source "$1"; declare -f env_config | grep -c \'SVC_PIDCHECK=""\''
|
||||
)
|
||||
self.assertEqual(out.strip(), "1")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -437,6 +437,80 @@ class SvcAutoRecoverTests(unittest.TestCase):
|
||||
thief.terminate()
|
||||
thief.wait(timeout=10)
|
||||
|
||||
# ---- 4b. 本服务孤儿占口(无 pidfile)→ start 回收后成功;外部占口仍拒 ----
|
||||
|
||||
def _wait_port(self, port, timeout=5.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
s = socket.socket()
|
||||
s.settimeout(0.2)
|
||||
try:
|
||||
s.connect(("127.0.0.1", port))
|
||||
return True
|
||||
except OSError:
|
||||
time.sleep(0.05)
|
||||
finally:
|
||||
s.close()
|
||||
return False
|
||||
|
||||
def _ss_can_attribute_pid(self):
|
||||
"""reclaim_own_port /「非本服务」归因依赖 ss -ltnp 的 pid= 字段。
|
||||
|
||||
CI 容器常无 iproute2 或 ss 不带进程信息——此时只能探测占用、不能归因,
|
||||
相关用例跳过(拒绝启动的兜底路径仍由「未能识别占用者」覆盖)。
|
||||
"""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["ss", "-ltnp"], capture_output=True, text=True, timeout=5)
|
||||
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
return out.returncode == 0 and "pid=" in out.stdout
|
||||
|
||||
def test_start_reclaims_own_orphan_without_pidfile(self):
|
||||
"""模拟 update 误清 pidfile 后本 bin 仍占口:裸 start 应回收并拉起新进程。"""
|
||||
if not self._ss_can_attribute_pid():
|
||||
self.skipTest("ss -ltnp 无法归因 PID(reclaim_own_port 需此能力)")
|
||||
port = 18812
|
||||
self.seed_agent("dev", port)
|
||||
self.start_ok("dev")
|
||||
old = self.pid_of("dev")
|
||||
self.assertTrue(self.pid_alive(old))
|
||||
# 只删 pidfile、不杀进程——对齐 2026-10-12 genui 现场(list=未运行、端口仍监听)。
|
||||
(self.runtime("dev") / "svc.pid").unlink()
|
||||
self.assertIsNone(self.pid_of("dev"))
|
||||
self.assertTrue(self.pid_alive(old))
|
||||
self.assertTrue(self._wait_port(port))
|
||||
result = self.start_ok("dev")
|
||||
self.assertIn("残留", result.stdout)
|
||||
new = self.pid_of("dev")
|
||||
self.assertTrue(self.pid_alive(new), result.stdout)
|
||||
self.assertNotEqual(new, old)
|
||||
self.assertFalse(self.pid_alive(old))
|
||||
stop_events = self.history_events("dev", "stop")
|
||||
self.assertTrue(any("reclaim_own_port" in l for l in stop_events), stop_events)
|
||||
|
||||
def test_start_rejects_foreign_port_occupant(self):
|
||||
"""外部 python http.server 占口:start 必须失败并提示非本服务。"""
|
||||
port = 18813
|
||||
self.seed_agent("dev", port)
|
||||
thief = subprocess.Popen(
|
||||
["python3", "-m", "http.server", str(port), "--bind", "127.0.0.1"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
self.addCleanup(thief.terminate)
|
||||
self.assertTrue(self._wait_port(port), "thief 未能监听")
|
||||
try:
|
||||
result = self.svc("dev", "start", check=False)
|
||||
self.assertEqual(result.returncode, 1, result.stdout)
|
||||
# 有 ss 时归因到外部进程 →「非本服务」;无 ss 时仅能探测 →「未能识别占用者」。
|
||||
# 两种路径都拒绝启动,不拉起本服务。
|
||||
self.assertTrue(
|
||||
("非本服务" in result.stdout) or ("未能识别占用者" in result.stdout),
|
||||
result.stdout)
|
||||
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):
|
||||
|
||||
Reference in New Issue
Block a user