diff --git a/.task/feature-svc-identity-u1.md b/.task/feature-svc-identity-u1.md index 24287e59..fbbd2c61 100644 --- a/.task/feature-svc-identity-u1.md +++ b/.task/feature-svc-identity-u1.md @@ -7,33 +7,45 @@ - 权威仓:ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Dev.git;目标分支:main;R0(initial_base_sha)= d8a269ab2fd20e4783ba0cde6a4794a702ad7571 - 工作区:/home/weichen/projects/Agent-System-Dev-wt-feature-svc-identity-u1(worktree);分支:feature/svc-identity-u1 -- 授权与交付终点:<本次允许的动作与终点;未授权事项写明> +- 授权与交付终点:用户 2026-10-12 交付根因诊断与 U1/U2/U3 增量方案(会话记录:rdm1/rdmcand 批量更新三连败定位);本轮范围 = U1 的 Dev 仓切片(svc.sh + 测试);交付终点 = 本 PR 可合入(实施与自检产物 + 局部测试,人工评审后合入)。不自行实施:服务重启/现场恢复、U2 失败恢复、U3 真实组合门禁、Backend/RDM 跨仓改动。 - 交付模式:manual(逐阶段人工评审);明确授权后可记为 authorized-continuous - 自动推进授权:无;授权范围:无;授权来源:无;授权终点:无;停止条件:无(未授权) -- 当前阶段:需求拆解;当前增量:I1(待定义) +- 当前阶段:实施与自检(产物已备,待人工评审);当前增量:I1(svc.sh 身份隔离+统一入口+候选门禁)+ I2(回归测试) - 流程依据:仓库根目录 policy/delivery.md(归档后仍按仓库根目录定位) ## 目标与非目标 -<预期结果、受影响入口/模块/契约;明确不做什么。> +目标:消除 svc.sh 批量生命周期中实例身份串用与「停服后才发现身份不可用」两类事故根因(2026-10-12 rdm1/rdmcand 各 3 次更新失败、登记 0aab6c3 ≠ 磁盘 894df2b): + +1. `env_config` 按实例重置 `AGENT_SYSTEM_INSTANCE_ID/DBREF`(批内串用根因); +2. `svc_bin_has_identity` 探测无副作用化:timeout 护栏 + serve 痕迹否决(RDM 产物 identity 未知位置参数落入服务路径、门禁报文恰含 "identity ensure" 字样造成旧 grep 误报); +3. 身份建立收敛:ensure 只在领养(svc_manifest_migrate)与显式 init;普通启动 verify-only,缺行即事件; +4. `agent_do_deploy` 构建前置 + 候选身份门禁 `svc_deploy_identity_gate`:无 identity 能力的候选在**停旧服务之前**拒绝(旧服务/制品/登记不变);有能力候选停服前幂等补齐缺失身份行(外宾行 fail-closed); +5. `cmd_update` 批内接入 `svc_manifest_migrate`(update 与单实例生命周期同入口,rdm1 多次更新后仍无 manifest 的根因)。 + +非目标:U2(制品不可变版本目录、切换持久化、自动回退);U3(真实 Dev/RDM 组合 CI 门禁);Backend 仓 identity CLI 公共化(runIdentity 在 cmd/package main,RDM 无法复用)与 RDM 仓 main.go 接线(跨仓任务);rdm1/rdmcand 现场恢复(依赖 U1 验证 + RDM 接口,属 #457 R2 后续,按诊断报告须先核对 rdmcand 历史数据归属)。 ## 关联方向与规划 -- 适用状态:待核对(无适用规划时说明原因) -- 主记录路径/版本:待核对(路径按仓库根目录定位,归档后不变;只引用唯一正文) -- 对应里程碑:待核对 +- 适用状态:已有主记录,保留原批准;本轮为其 U1 增量的 Dev 仓切片 +- 主记录路径/版本:modules/documents/docs/proposals/2026-10-12-svc-unified-deploy-and-login-key.md r2(统一部署)与 2026-10-12-service-restart-data-recovery.md r3(恢复);诊断报告(会话交付,2026-10-12) +- 对应里程碑:统一部署 U1(实例身份流程统一) ## 需求分析 | ID | 描述 | 项目符合度及依据 | 优先级 | 验收条件 | 依赖 | 结论 | |---|---|---|---|---|---|---| -| R1 | 待填写 | 待评估 | 待定 | 待定义 | 待确认 | 待评审 | +| U1-1 | 批量 update 身份环境不串用 | 符合:env_config 已重置 SVC_*(#457)但漏 AGENT_SYSTEM_*;rdm1/rdmcand svc.log 2026-10-12 实况为 dev 身份泄漏 | P0 | 棘轮:env_config unset 身份变量;update 后 serve 只含本服务身份(回归断言) | #457 | 已实现 | +| U1-2 | 能力探测无副作用 | 符合:旧实现无 timeout、grep ensure 被 RDM 门禁报文误报(本轮实测复现) | P0 | 探测单测:报文含 ensure 不误报;identity 挂死有界返回无能力 | 无 | 已实现 | +| U1-3 | 启动只验证不建身份 | 符合:诊断裁定「缺行即事件」;现状每次启动 ensure 掩盖身份丢失(rdmcand) | P0 | 缺行裸 start 拒绝且不建空行;显式 init 补齐后恢复 | 无 | 已实现 | +| U1-4 | 候选身份门禁在停服前 | 符合:事故时序 = 停服→覆盖→启动即拒→混合状态无恢复路径 | P0 | 无能力候选:拒绝且旧进程/制品/登记逐项不变;有能力候选:停服前补齐身份行后完整更新成功 | 无 | 已实现 | +| U1-5 | update 接入统一身份入口 | 符合:update 直入 agent_do_deploy 绕过 svc_manifest_migrate(rdm1 多次更新无 manifest) | P0 | update 路径 manifest 迁移生效(回归经「候选补行后完整更新」断言) | 无 | 已实现 | ## 方案与计划 -- 方案引用:待填写(含对象版本) -- 实施计划引用:待填写(含增量与需求 ID) -- 测试计划引用:待填写(含需求 ID 与验收条件) +- 方案引用:用户诊断报告 U1 增量(2026-10-12)+ docs/recovery/u1-identity-manifest.md §5(身份建立仅显式 init/受控领养、能力探测须无副作用) +- 实施计划:I1 = svc.sh 五点改动(scripts/svc.sh);I2 = 测试(tests/test_svc_identity.py U1 回归组、tests/test_svc_precheck.py 棘轮、tests/test_svc_recover.py 桩 serve 身份环境记录) +- 测试计划:U1-1→棘轮+update 回归;U1-2→探测单测;U1-3→缺行即事件+init 恢复;U1-4→候选拒绝保现场+候选补行完整更新;U1-5→同 U1-4 路径;全套 `make test-governance` + tests.test_svc 显式复跑(rdm 部署端到端零回归) ## 阶段评审 @@ -54,7 +66,8 @@ | 增量/试验 | 假设 | 范围 | 判断和停止条件 | 批准依据 | 结果 | 变更影响 | 需重审补测 | |---|---|---|---|---|---|---|---| -| 待定义 | — | — | — | 未批准 | NOT_RUN | 待评估 | 待评估 | +| I2 夹具:noid 桩形态 | exec 转发包装可模拟 RDM 无 identity 形态 | tests/test_svc_identity.py 夹具 | 判据=换装后 start_ok 成功;失败即重造夹具,不改产品 | 阶段内自检(policy 反馈循环) | 发现 exec 转发使 serve cmdline 失去实例 bin 路径、被 pidcheck 误判死;修正为自包含桩(identity 分支替换)后三测转绿 | 仅测试夹具 | 无 | +| I1 探测语义验证 | RDM 门禁报文含 "identity ensure" 会误报能力 | svc.sh svc_bin_has_identity + 探测单测 | 单测三态(可用/误报否决/挂死有界)全绿即成立 | 阶段内自检 | 通过(含手工复现取证:set -euo pipefail 下探针 shell 须用 if 条件上下文) | 无产品变更外溢 | 无 | ## 同步记录 diff --git a/scripts/svc.sh b/scripts/svc.sh index aea64f93..920e1ed2 100755 --- a/scripts/svc.sh +++ b/scripts/svc.sh @@ -1055,6 +1055,12 @@ svc_manifest_migrate() { out="$(python3 "$MANIFEST_PY" "$ENV_DIR" migrate 2>&1)" || rc=$? if [ "$rc" -eq 0 ]; then c_ok "[$ENV_NAME] manifest:$(printf '%s' "$out" | tail -1)" + # 领养即受控建身份:DB marker 只在领养(此处)与显式 init 建立;普通启动只 + # 验证不补行——缺行即事件(2026-10-12 裁定,rdmcand 实况:每次启动静默 + # ensure 会掩盖身份丢失)。领养时旧产物可能无 identity 能力而跳过补行,由 + # 部署候选门禁 svc_deploy_identity_gate 在下次换产物前用候选补齐。 + resolve_db_config 2>/dev/null || true + svc_db_identity_ensure || return 1 return 0 fi if [ "$rc" -eq 4 ]; then @@ -1068,7 +1074,9 @@ svc_manifest_migrate() { } # svc_manifest_verify:启动前身份校验——数据 marker 存在且 instance_id 匹配; # 生效 --data / 数据库绑定与冻结锚点一致。失败即拒绝启动(KTD1/KTD2,I4)。 -# 顺序:文件 marker → DB ensure(空表幂等补)→ DB verify(缺/错即拦)。 +# 顺序:文件 marker → DB verify(缺/错即拦)。ensure 只在领养(svc_manifest_migrate) +# 与显式 init——普通启动不建身份,缺行即事件(修复路径:svc init,或换具备 +# identity 能力的产物更新时由候选门禁补齐)。 svc_manifest_verify() { manifest_ok || return 0 local out @@ -1078,8 +1086,7 @@ svc_manifest_verify() { c_err " 数据根/数据库绑定是冻结身份;换根/重绑须显式迁移或登记命令(docs/recovery/u1-identity-manifest.md §5)" return 1 fi - # U1 I3:文件校验通过后再碰库——避免损坏 manifest 时 ensure 先 traceback。 - svc_db_identity_ensure || return 1 + # U1 I3:文件校验通过后再碰库——避免损坏 manifest 时身份 CLI 先 traceback。 svc_db_identity_verify || return 1 return 0 } @@ -1105,20 +1112,33 @@ print("%s\t%s\t%s" % (m["instance_id"], dbref, data)) PY } +# svc_bin_has_identity [BIN]:产物是否具备 identity 管理子命令(ensure/verify)。 +# 探测必须无副作用、有界:timeout 护栏(未知子命令直落服务态的产物会挂死或占口); +# serve 痕迹与身份门禁报错文本不算能力证据——2026-10-12 rdm1/rdmcand 事故中 RDM +# 产物对 identity 未知位置参数落入服务启动路径,报错恰好含「identity ensure」字样, +# 旧实现 grep ensure 误报为有能力。仅 usage 形态(同时含 identity 与 ensure、 +# 无服务态痕迹、未超时)才认可。 svc_bin_has_identity() { - [ -x "${ENV_BIN:-}" ] || return 1 - local out - out="$("$ENV_BIN" identity 2>&1 || true)" - printf '%s' "$out" | grep -q ensure + local bin="${1:-${ENV_BIN:-}}" + [ -x "$bin" ] || return 1 + local out rc=0 + out="$(timeout 5 "$bin" identity 2>&1)" || rc=$? + [ "$rc" -eq 124 ] && return 1 + case "$out" in + *serving*|*"agent-system starting"*|*"identity missing"*|*"listen"*) return 1 ;; + esac + printf '%s' "$out" | grep -q identity && printf '%s' "$out" | grep -q ensure } svc_db_identity_ensure() { - # 显式建立 / I2→I3 引导:表空插入;一致幂等;不一致 fail-closed。 + # 显式建立 / 领养补行(I2→I3 引导):表空插入;一致幂等;不一致 fail-closed。 # 非 agent 或无 identity 子命令的旧产物:跳过(不硬断 I2)。 + # 可选参数 BIN:用指定产物(如部署候选)执行——默认当前安装产物。 + local bin="${1:-${ENV_BIN:-}}" manifest_ok || return 0 local kind; kind="$(svc_get kind)" [ "$kind" = agent ] || return 0 - svc_bin_has_identity || return 0 + svc_bin_has_identity "$bin" || return 0 local line iid dbref data line="$(svc_db_identity_args)" || return 1 IFS=$'\t' read -r iid dbref data <<<"$line" @@ -1127,7 +1147,7 @@ svc_db_identity_ensure() { args+=(--dsn "$DB_DSN") fi local out rc=0 - out="$("$ENV_BIN" "${args[@]}" 2>&1)" || rc=$? + out="$("$bin" "${args[@]}" 2>&1)" || rc=$? if [ "$rc" -ne 0 ]; then c_err "[$ENV_NAME] DB 身份 ensure 失败:$out" return 1 @@ -1161,6 +1181,35 @@ svc_db_identity_verify() { return 0 } +# svc_deploy_identity_gate SRCBIN:部署候选的身份门禁——在停旧服务、覆盖制品之前 +# 执行(2026-10-12 rdm1/rdmcand 事故:停服→覆盖→启动即被身份门禁拒绝,只剩 +# 「登记版本≠磁盘制品」的混合状态且无恢复路径)。规则: +# 1) 未入 manifest 治理(全新实例,无 manifest.json):放行——首次部署无旧状态可保; +# 2) 非 agent 类型:放行; +# 3) 候选产物无 identity 管理能力:拒绝——装上即启动被基座身份门禁拒绝, +# 登记与旧制品/旧进程保持不变(下游组合仓接齐 identity 接口前不可更新); +# 4) 候选有能力:先以候选幂等补齐 DB 身份行(领养时旧产物可能无能力而未补; +# 外宾行/不一致 fail-closed),成功才放行——把「缺行」在停服前治好, +# 避免换完制品才发现 start 起不来。 +svc_deploy_identity_gate() { + local srcbin="$1" + manifest_ok || return 0 + local kind; kind="$(svc_get kind)" + [ "$kind" = agent ] || return 0 + if ! svc_bin_has_identity "$srcbin"; then + c_err "[$ENV_NAME] 候选产物不支持 identity 管理:$srcbin" + c_err " manifest 治理实例拒绝安装(装上即被启动身份校验拒绝);旧服务/旧制品/登记保持不变" + c_err " 处理:换具备 identity ensure/verify 的版本部署,或 scripts/svc.sh rm $ENV_NAME --purge 后放弃治理重新部署" + return 1 + fi + resolve_db_config 2>/dev/null || true + svc_db_identity_ensure "$srcbin" || { + c_err "[$ENV_NAME] 候选补齐 DB 身份失败(外宾行/不一致 fail-closed);旧服务/旧制品/登记保持不变" + return 1 + } + return 0 +} + # svc_mark_autostart / svc_clear_autostart:期望运行态登记(成功 start 置 running, # 显式 stop 置 stopped;进程自行死亡不动——recover 据 svc.env 派生的 autostart 判断)。 svc_mark_autostart() { manifest_ok && python3 "$MANIFEST_PY" "$ENV_DIR" set-desired running >/dev/null 2>&1 && return 0; svc_set_key autostart 1 >/dev/null 2>&1 || true; } @@ -1212,6 +1261,12 @@ env_config() { # 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="" + # U1:身份环境同样按实例重置——上一服务 verify 成功后导出的 + # AGENT_SYSTEM_INSTANCE_ID/DBREF 不得串给下一服务(批量 update 单进程逐服务解析; + # 2026-10-12 实况:dev 的身份漏给 rdm1/rdmcand,叠加 RDM 产物无 identity 子命令 + # 跳过校验,serve 带外宾身份即被基座门禁拒绝)。serve 只允许拿到本服务 verify + # 写入的身份。 + unset AGENT_SYSTEM_INSTANCE_ID AGENT_SYSTEM_DBREF local registered="" [ -f "$ENV_DIR/svc.env" ] && registered="$(svc_kind "$name")" SVC_KIND="${registered:-agent}" @@ -1813,23 +1868,44 @@ agent_do_deploy() { return 1 fi hist_event deploy begin "$(read_pid)" "from=$SRC_LABEL" - do_stop_quiet + # U1/U2(构建前置):先构建/物化候选并过身份门禁,再停旧服务——构建失败或候选 + # 不合格时旧服务继续运行、旧制品与登记不动(2026-10-12 rdm1/rdmcand 事故:停服 + # →覆盖→启动即拒,只留「登记版本≠磁盘制品」的混合状态且无恢复路径)。 local wt RDM_UI_OK=0 case "$SRC_KINDSRC" in local) c_info "[$ENV_NAME] 构建当前工作区(make build)——唯一写主检出构建产物的路径" make -C "$ROOT" build || { hist_event deploy fail "$(read_pid)" "构建失败($SRC_LABEL)"; return 1; } + svc_deploy_identity_gate "$BIN" \ + || { hist_event deploy fail "$(read_pid)" "候选身份门禁拒绝($SRC_LABEL)"; return 1; } + do_stop_quiet install_artifacts "$BIN" "$ROOT/plugins" \ || { hist_event deploy fail "$(read_pid)" "产物复制失败"; return 1; } ;; dev) wt="$(materialize_dev_commit "$SRC_FULL" "$SRC_SHA" build)" \ || { hist_event deploy fail "$(read_pid)" "物化/构建失败($SRC_LABEL)"; return 1; } + if ! svc_deploy_identity_gate "$wt/bin/agent-system"; then + hist_event deploy fail "$(read_pid)" "候选身份门禁拒绝($SRC_LABEL)" + recycle_worktree "$SRC_SHA" + return 1 + fi + do_stop_quiet install_artifacts "$wt/bin/agent-system" "$wt/plugins" \ || { hist_event deploy fail "$(read_pid)" "产物复制失败"; return 1; } recycle_worktree "$SRC_SHA" ;; release) + local relbin="$REL_ROOT/build/$REL_VERSION/agent-system" + if [ ! -f "$relbin" ]; then + c_err "[$ENV_NAME] Releases 仓无制品:$relbin" + c_err "先在 Releases 仓构建:scripts/build-release.sh $REL_VERSION(或换版本:scripts/svc.sh commits release)" + hist_event deploy fail "$(read_pid)" "制品缺失($SRC_LABEL)" + return 1 + fi + svc_deploy_identity_gate "$relbin" \ + || { hist_event deploy fail "$(read_pid)" "候选身份门禁拒绝($SRC_LABEL)"; return 1; } + do_stop_quiet materialize_release || { hist_event deploy fail "$(read_pid)" "物化/校验失败($SRC_LABEL)"; return 1; } ;; rdm) @@ -1837,6 +1913,12 @@ agent_do_deploy() { || { hist_event deploy fail "$(read_pid)" "物化/构建失败($SRC_LABEL)"; return 1; } # RDM 组合插件编译进二进制(provides 无声明式资产)——插件目录按空集安装。 mkdir -p "$wt/.svc-empty-plugins" + if ! svc_deploy_identity_gate "$wt/bin/agent-system-rdm"; then + hist_event deploy fail "$(read_pid)" "候选身份门禁拒绝($SRC_LABEL)" + recycle_worktree "rdm-$SRC_SHA" "$RDM_SRC_DIR" + return 1 + fi + do_stop_quiet install_artifacts "$wt/bin/agent-system-rdm" "$wt/.svc-empty-plugins" \ || { hist_event deploy fail "$(read_pid)" "产物复制失败"; return 1; } # RDM 自带零构建工作台(web/)落 htdocs/;收尾自动部署伴生 ${name}web。 @@ -2326,7 +2408,8 @@ agent_do_start() { hosts="$(env_var "$ENV_NAME" ALLOWED_HOSTS)" [ -n "$insecure" ] && guard_env+=("AGENT_SYSTEM_INSECURE_LISTEN=$insecure") [ -n "$hosts" ] && guard_env+=("AGENT_SYSTEM_ALLOWED_HOSTS=$hosts") - # U1 身份校验:文件 marker + DB marker(ensure 幂等补空表;不一致 fail-closed)。 + # U1 身份校验:文件 marker + DB verify(不一致 fail-closed;ensure 只在领养与 + # 显式 init,普通启动不补行——缺行即事件,见 svc_manifest_verify)。 svc_manifest_verify || { hist_event start fail "" "manifest 身份校验失败"; return 1; } # 预检(快速拦截):非回环地址且认证判定为关时提前拒绝,报错可操作—— # 不等二进制 listen guard 以"进程即退"暴露;二进制守卫仍是权威。 @@ -4311,6 +4394,17 @@ cmd_update() { hist_event update begin "$(read_pid)" "from=$from to=$tov" CLI_FROM="$deploy_from" svc_lock wait + # U1:批量更新与单实例生命周期同入口——锁内 manifest 迁移(领养/身份治理; + # 2026-10-12 rdm1 实况:update 直入 agent_do_deploy 绕过迁移,多次更新后仍无 + # manifest)。归属冲突按该服务更新失败计,其余服务继续。 + if ! svc_manifest_migrate; then + hist_event update fail "$(read_pid)" "from=$from to=$tov(manifest 迁移中止:身份治理冲突)" + c_err "[$name] 更新失败(manifest 迁移中止),其余服务继续" + svc_unlock + CLI_FROM="" + failed=$((failed + 1)) + continue + fi if agent_do_deploy; then hist_event update ok "$(read_pid)" "from=$from to=$tov" c_ok "[$name] 已更新:$from → $tov" diff --git a/tests/test_svc_identity.py b/tests/test_svc_identity.py index 043f1975..d339dc24 100644 --- a/tests/test_svc_identity.py +++ b/tests/test_svc_identity.py @@ -210,6 +210,213 @@ class IdentityTests(tsr.SvcAutoRecoverTests): con.close() return rows + # ---- U1 批量隔离、候选门禁与启动语义(2026-10-12 rdm1/rdmcand 事故回归) ---- + + def noid_stub(self): + """自包含「无 identity」桩:复用 recover STUB,把 identity 分支换成 2026-10-12 + RDM 组合产物形态——identity 子命令落入服务路径、被身份门禁拒绝,报错文本 + 恰含「identity ensure」字样(旧探测 grep ensure 的误报源)。不 exec 转发: + serve 进程 cmdline 必须保留实例 bin 路径(pidcheck 契约与真实产物一致)。""" + p = self.root / "stub-agent-system-noid" + if not p.exists(): + stub = tsr.STUB + head, sep, tail = stub.partition(" identity)") + assert sep, "STUB 缺 identity 分支锚点" + _, sep2, tail2 = tail.partition(" exit $? ;;") + assert sep2, "STUB 缺 identity 分支结尾锚点" + noid_case = ( + " identity)\n" + " echo \"system identity: system identity missing: agent_system_identity 无行" + "(先 svc init / identity ensure,禁止静默建空身份)\" >&2\n" + " exit 1 ;;") + p.write_text(head + noid_case + tail2) + p.chmod(0o755) + return p + + def install_noid_bin(self, name): + """把实例磁盘产物换成无 identity 形态(rdm1/rdmcand 事故现场的制品状态)。""" + shutil.copy2(self.noid_stub(), self.runtime(name) / "bin" / "agent-system") + + def set_kv(self, name, key, value): + f = self.runtime(name) / "svc.env" + lines = f.read_text().splitlines() + out, hit = [], False + for l in lines: + if l.startswith(key + "="): + out.append("%s=%s" % (key, value)); hit = True + else: + out.append(l) + if not hit: + out.append("%s=%s" % (key, value)) + f.write_text("\n".join(out) + "\n") + + def serve_record(self): + f = self.trace / "serve.txt" + return f.read_text() if f.exists() else "" + + def clear_serve_record(self): + (self.trace / "serve.txt").unlink(missing_ok=True) + + def git_ok(self, cwd, *args): + proc = subprocess.run(["git", *args], cwd=cwd, env=self.env, text=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=60) + self.assertEqual(proc.returncode, 0, proc.stdout) + + def git_sha(self, cwd, rev): + proc = subprocess.run(["git", "rev-parse", "--short", rev], cwd=cwd, + env=self.env, text=True, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=30) + return proc.stdout.strip() + + def _make_track_repo(self, artifact, target, with_web=False): + """双提交源仓夹具(旧/新两版):Makefile 产物 = artifact 复制为 target。""" + remote, work = self.root / "trackremote.git", self.root / "trackwork" + for p in (remote, work): + if p.exists(): + shutil.rmtree(p) + self.git_ok(self.root, "init", "--bare", "--initial-branch=main", str(remote)) + self.git_ok(self.root, "init", "--initial-branch=main", str(work)) + (work / "Makefile").write_text( + "build:\n\tmkdir -p bin\n\tcp %s bin/%s\n" % (artifact, target)) + if with_web: + (work / "web").mkdir() + (work / "web" / "index.html").write_text("rdm\n") + self.git_ok(work, "add", ".") + self.git_ok(work, "commit", "-m", "track v1") + self.git_ok(work, "remote", "add", "origin", str(remote)) + self.git_ok(work, "push", "-u", "origin", "main") + (work / "v2.marker").write_text("v2\n") + self.git_ok(work, "add", ".") + self.git_ok(work, "commit", "-m", "track v2") + self.git_ok(work, "push") + return remote, self.git_sha(work, "HEAD~1"), self.git_sha(work, "HEAD") + + def make_src_repo(self): + """dev 轨道:产物 = 可用 identity 桩;预克隆进 SVC_SRC_DIR(srccache)。""" + remote, old, new = self._make_track_repo(self.stub, "agent-system") + cache = self.root / "srccache" + if cache.exists(): + shutil.rmtree(cache) + self.git_ok(self.root, "clone", str(remote), str(cache)) + return old, new + + def make_rdm_repo(self, artifact): + """rdm 轨道:产物 = 指定桩(可注入无 identity 形态)。""" + return self._make_track_repo(artifact, "agent-system-rdm", with_web=True) + + def rdm_env(self, remote): + env = dict(self.env) + env.update({"SVC_RDM_SRC_DIR": str(self.root / "rdmcache"), + "SVC_RDM_ORIGIN": str(remote)}) + return env + + def test_U1_update_refuses_identityless_candidate_keeps_old_state(self): + """manifest 治理实例 × 无 identity 候选(RDM 旧形态):停服前拒绝—— + 旧服务继续运行、旧制品与登记不变(2026-10-12 rdm1/rdmcand 反例:停服→ + 覆盖→启动即拒,只剩混合状态)。""" + remote, old_sha, new_sha = self.make_rdm_repo(self.noid_stub()) + self.seed_agent("rdx", 9451) + self.start_ok("rdx") # 领养:manifest + DB 行 + self.svc("rdx", "stop") + self.install_noid_bin("rdx") # 磁盘产物退化为无 identity(事故现场) + self.set_kv("rdx", "source", "rdm@%s" % old_sha) + self.start_ok("rdx") # 校验跳过,照常运行 + pid = self.pid_of("rdx") + bin_before = (self.runtime("rdx") / "bin" / "agent-system").read_bytes() + + result = self.svc("update", "rdx", check=False, env=self.rdm_env(remote)) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn("候选产物不支持 identity 管理", result.stdout) + # 拒绝发生在停旧服务之前:进程、制品、登记全部保持 + self.assertTrue(self.running("rdx"), "拒绝不得停掉可用旧服务") + self.assertEqual(self.pid_of("rdx"), pid) + self.assertEqual((self.runtime("rdx") / "bin" / "agent-system").read_bytes(), + bin_before, "旧制品不得被覆盖") + self.assertEqual(self.kv("rdx", "source"), "rdm@%s" % old_sha) + hist = (self.runtime("rdx") / "history.jsonl").read_text() + self.assertIn('"event":"update"', hist) + self.assertIn('"result":"fail"', hist) + self.svc("rdx", "stop") + + def test_U1_update_ensures_missing_row_before_stop(self): + """领养时未能补行的实例(旧产物无 identity):候选有能力时,update 在 + 停服前用候选补齐 DB 身份行——完整更新成功;serve 拿到的是本服务自己的 + 身份(非批内残留)。""" + old_sha, new_sha = self.make_src_repo() + self.seed_agent("rowx", 9452) + self.start_ok("rowx") + self.svc("rowx", "stop") + self.install_noid_bin("rowx") + self.set_kv("rowx", "source", "dev@%s" % old_sha) + self.start_ok("rowx") + # 模拟领养时未补行(rdm1 现状):清空 DB 身份行 + db = Path(self.manifest("rowx")["identity"]["data_root"]) / "agent-system.db" + con = sqlite3.connect(str(db)) + con.execute("DELETE FROM agent_system_identity") + con.commit() + con.close() + + self.clear_serve_record() + result = self.svc("update", "rowx") + self.assertIn("已更新", result.stdout) + self.assertEqual(self.kv("rowx", "source"), "dev@%s" % new_sha) + iid = self.manifest("rowx")["instance_id"] + self.assertEqual(self.db_identity_row("rowx"), [(iid, "sqlite")], + "候选应在停服前补齐身份行") + self.assertTrue(self.running("rowx")) + self.assertIn("AGENT_SYSTEM_INSTANCE_ID=%s" % iid, self.serve_record(), + "serve 只允许拿到本服务自己的身份") + self.svc("rowx", "stop") + + def test_U1_bare_start_missing_row_is_event_init_recovers(self): + """普通启动只验证不补行:DB 行缺失 → 拒绝且不建空行(缺行即事件); + 显式 init 补齐后恢复。""" + self.seed_agent("rowm", 9453) + self.start_ok("rowm") + iid = self.manifest("rowm")["instance_id"] + self.svc("rowm", "stop") + db = Path(self.manifest("rowm")["identity"]["data_root"]) / "agent-system.db" + con = sqlite3.connect(str(db)) + con.execute("DELETE FROM agent_system_identity") + con.commit() + con.close() + + result = self.svc("rowm", "start", check=False) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn("DB 身份校验失败", result.stdout) + self.assertFalse(self.running("rowm")) + self.assertEqual(self.db_identity_row("rowm"), [], "缺行不得被 start 静默补建") + + self.svc("rowm", "init") # 修复路径:显式 ensure + self.assertEqual(self.db_identity_row("rowm"), [(iid, "sqlite")]) + self.start_ok("rowm") + self.svc("rowm", "stop") + + def test_U1_capability_probe_no_false_positive_no_hang(self): + """能力探测无副作用:含 ensure 字样的服务态门禁报错不算能力(旧实现 + 误报点);identity 即挂死的产物探测有界返回无能力。""" + def probe(binpath): + # svc.sh 顶层启用 errexit:source 后裸调用失败函数会杀掉探针 shell, + # 用 if 条件上下文(errexit 豁免)取回真实返回码。 + return subprocess.run( + ["bash", "-c", + 'source "%s" >/dev/null 2>&1; ENV_BIN="%s"; ' + 'if svc_bin_has_identity; then echo rc=0; else echo rc=$?; fi' + % (self.root / "scripts" / "svc.sh", binpath)], + cwd=self.root, env=self.env, text=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + timeout=30).stdout.strip() + + self.assertIn("rc=0", probe(self.stub), "identity 桩应判有能力") + self.assertIn("rc=1", probe(self.noid_stub()), + "报错文本含 ensure 不得误报为有能力") + hang = self.root / "stub-hang" + hang.write_text( + '#!/usr/bin/env bash\ncase "${1:-}" in identity) sleep 30 ;; ' + '*) exec "%s" "$@" ;; esac\n' % self.stub) + hang.chmod(0o755) + self.assertIn("rc=1", probe(hang), "探测挂死须有界返回无能力") + def test_I3_start_ensures_sqlite_identity(self): self.seed_agent("idb1", 9441) self.start_ok("idb1") diff --git a/tests/test_svc_precheck.py b/tests/test_svc_precheck.py index 3da6e9ba..bdfe4559 100644 --- a/tests/test_svc_precheck.py +++ b/tests/test_svc_precheck.py @@ -236,6 +236,16 @@ class ResolverStateIsolation(unittest.TestCase): ) self.assertEqual(out.strip(), "1") + def test_env_config_resets_identity_env(self): + # 棘轮:env_config 必须重置 AGENT_SYSTEM_INSTANCE_ID/DBREF(删除即回归 + # 「批量 update 前一服务 verify 导出的身份串给下一服务」——2026-10-12 + # rdm1/rdmcand:dev 身份漏给 RDM,serve 带外宾身份被门禁拒绝)。 + out = _bash( + 'source "$1"; declare -f env_config | ' + 'grep -c \'unset AGENT_SYSTEM_INSTANCE_ID AGENT_SYSTEM_DBREF\'' + ) + self.assertEqual(out.strip(), "1") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_svc_recover.py b/tests/test_svc_recover.py index 3d32e843..77d6d9bf 100644 --- a/tests/test_svc_recover.py +++ b/tests/test_svc_recover.py @@ -111,6 +111,11 @@ PYID exit 0 ;; esac printf 'args: %s\n' "$*" > "$trace_dir/serve.txt" + # 身份环境记录(U1 批量隔离回归观测点):serve 实际拿到谁的实例身份。 + if [ -n "${AGENT_SYSTEM_INSTANCE_ID:-}" ]; then + printf 'AGENT_SYSTEM_INSTANCE_ID=%s\nAGENT_SYSTEM_DBREF=%s\n' \ + "$AGENT_SYSTEM_INSTANCE_ID" "${AGENT_SYSTEM_DBREF:-sqlite}" >> "$trace_dir/serve.txt" + fi addr=""; data="./data" while [ $# -gt 0 ]; do case "$1" in --addr) addr="$2"; shift 2 ;; --data) data="$2"; shift 2 ;; *) shift ;; esac