#!/usr/bin/env bash # ============================================================================= # migrate-share-per-agent.sh —— /opt/mao-data/share 按 Agent 隔离(形态 C)迁移脚本 # # 依据:docs/plan/2026-11-09-share-per-agent-isolation-technical-design.md(决策 2026-11-09) # · 形态 C:agent-experience/{A}(原位)+ agents/{A}(私有区),取消公共投放区 # · 零兼容代码:本脚本承担全部存量数据搬迁与引用更新 # # 用法: # bash migrate-share-per-agent.sh plan # 默认:只读预演(不动任何文件/数据) # bash migrate-share-per-agent.sh apply --phase=1 # 数据面:备份+建结构+分发+README+删试验副本 # bash migrate-share-per-agent.sh apply --phase=2 # 引用更新:DB + 文件清单(逐文件先备份) # bash migrate-share-per-agent.sh apply --phase=3 # 清理旧残留(须代码部署并验证后执行) # bash migrate-share-per-agent.sh verify # 结构与残留校验(默认 pre-phase3 模式) # bash migrate-share-per-agent.sh verify --post-phase3 # phase3 之后:旧残留一律计 FAIL # # 选项:--yes(跳过二次确认) --share-dir=DIR --backend-env=FILE # --allow-missing phase2 规则目标文件缺失时不中断(仅告警;verify 残留扫描兜底) # --no-db verify 显式跳过 DB 校验(DB 不可达且未给该参数时计 FAIL) # 阶段关系:phase1 零断点(复制保留,旧路径继续可用)→ phase2 → 部署代码 # → verify --pre-phase3 + 回归 → 观察 → phase3 删旧残留 → verify --post-phase3 # 幂等:重复执行安全;命中 0 次打印"无需更新"并继续。 # ============================================================================= set -euo pipefail SHARE_DIR="/opt/mao-data/share" BACKEND_ENV="/opt/mao/backend-ts/.env" BACKUP_ROOT="/opt/mao-data/backup" AGENTS=(1 3 4 5 6 7) # 存量 Agent(2 未启用:无经验库/会话,按懒建处理;7 为 2026-11-09 18:13 新增,已后补) MODE="plan"; PHASE=""; ASSUME_YES=0 VERIFY_MODE="pre" # pre-phase3(默认)| post-phase3 ALLOW_MISSING=0 # phase2 规则目标文件缺失时是否放行 ALLOW_NO_DB=0 # verify 是否显式跳过 DB 校验 for arg in "$@"; do case "$arg" in plan|verify) MODE="$arg" ;; apply) MODE="apply" ;; --phase=*) PHASE="${arg#--phase=}" ;; --yes) ASSUME_YES=1 ;; --pre-phase3) VERIFY_MODE="pre" ;; --post-phase3) VERIFY_MODE="post" ;; --allow-missing) ALLOW_MISSING=1 ;; --no-db) ALLOW_NO_DB=1 ;; --share-dir=*) SHARE_DIR="${arg#--share-dir=}" ;; --backend-env=*) BACKEND_ENV="${arg#--backend-env=}" ;; *) echo "未知参数: $arg" >&2; exit 2 ;; esac done [[ "$MODE" == "apply" && ! "$PHASE" =~ ^[123]$ ]] && { echo "apply 需要 --phase=1|2|3" >&2; exit 2; } if [[ "$SHARE_DIR" != "/opt/mao-data/share" ]]; then echo "WARN: --share-dir 非默认值($SHARE_DIR):FILE_RULES 与 DB SQL 仍按 /opt/mao-data/share 硬编码," >&2 echo " verify 的残留扫描也可能误判;该参数仅建议用于演练/测试。" >&2 fi TS="$(date +%Y%m%d-%H%M%S)" BK="$BACKUP_ROOT/share-migrate-$TS" log() { printf '%s\n' "$*"; } note() { printf ' - %s\n' "$*"; } die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } head2() { printf '\n== %s ==\n' "$*"; } require_root() { [[ "$(id -u)" -eq 0 ]] || die "需要 root 执行(share 与工作区文件均为 root 属主)"; } confirm() { [[ "$ASSUME_YES" -eq 1 ]] && return 0 printf '\n确认执行以上动作?输入 yes 继续:' read -r answer || answer=""; [[ "$answer" == "yes" ]] || die "已取消" } # --- 文件替换规则:|||| ------------------------------------ FILE_RULES=( "${SHARE_DIR}/agent-experience/5/2026-11-05-应用接入Redis集群限流并发布测试环境.md||/opt/mao-data/share/settings.xml||/opt/mao-data/share/agents/5/settings.xml" "${SHARE_DIR}/agent-experience/5/2026-11-03-二方包升级兼容性验证.md||/opt/mao-data/share/settings.xml||/opt/mao-data/share/agents/5/settings.xml" "${SHARE_DIR}/agent-experience/5/2026-11-05-NTX大促推单与预售统计口径.md||/opt/mao-data/share/scripts/||/opt/mao-data/share/agents/5/scripts/" "${SHARE_DIR}/agent-experience/5/2026-11-06-定时任务性能评估.md||/opt/mao-data/share/scripts/||/opt/mao-data/share/agents/5/scripts/" "${SHARE_DIR}/agent-experience/5/2026-10-25-ASCM提前推单.md||/opt/mao-data/share/scripts/||/opt/mao-data/share/agents/5/scripts/" "${SHARE_DIR}/agent-experience/5/2026-10-27-腾炬服务商查询鉴权与业绩趋势.md||/opt/mao-data/share/tools/vtn-biz-cli-0.8.7/bin/vtn-biz-cli.js||skill-views//vtn-biz-cli/bin/vtn-biz-cli.js" "${SHARE_DIR}/agent-experience/5/2026-10-27-腾炬服务商查询鉴权与业绩趋势.md||/opt/mao-data/share/tools/vtn-biz-cli-1.0.2||skill-views//vtn-biz-cli" "${SHARE_DIR}/agent-experience/6/2026-11-06-tag-center标签数据未找到排查.md||/opt/mao-data/share/settings_t.xml||/opt/mao-data/share/agents/6/settings_t.xml" "${SHARE_DIR}/agent-experience/6/2026-11-06-rabbitmq-406-unknown-delivery-tag排查.md||/opt/mao-data/share/settings_t.xml||/opt/mao-data/share/agents/6/settings_t.xml" "/opt/mao-data/workspace/feishu-chat/3/oc_67837a63395d608df8e766ba96919fe0/AGENTS.md||/opt/mao-data/share/settings_t.xml||/opt/mao-data/share/agents/6/settings_t.xml" "/opt/mao-data/workspace/10/projects/feishu-chat-2-oc_155c79d46f86b12ec34fbb46e12e094e/docs/agents/goods-hulk-redis-limit.md||/opt/mao-data/share/settings.xml||/opt/mao-data/share/agents/5/settings.xml" "/opt/mao-data/workspace/10/projects/feishu-chat-2-oc_155c79d46f86b12ec34fbb46e12e094e/docs/agents/market-coupon-service-recbest-combination-cpu.md||/opt/mao-data/share/settings.xml||/opt/mao-data/share/agents/5/settings.xml" "/opt/mao-data/workspace/feishu-chat/2/oc_fb9ef40b6ff5b0c8cae6814f97c59a94/docs/agents/skill-cli-health-check.md||/opt/mao-data/share/scripts/||/opt/mao-data/share/agents/5/scripts/" "/opt/mao-data/workspace/feishu-chat/2/oc_fb9ef40b6ff5b0c8cae6814f97c59a94/docs/agents/skill-view-deployment.md||/opt/mao-data/share/scripts/||/opt/mao-data/share/agents/5/scripts/" ) # --- 小工具 ---------------------------------------------------------------- replace_in_file() { # $1 文件 $2 旧串 $3 新串 $4 是否写(1/0) local file="$1" old="$2" new="$3" apply="$4" if [[ ! -f "$file" ]]; then note "[跳过·不存在] $file"; return 0; fi python3 - "$file" "$old" "$new" "$apply" <<'PY' import os, shutil, sys path, old, new, apply = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] == '1' s = open(path, encoding='utf-8').read() n = s.count(old) if n == 0: print(f" [0命中] {path}") elif apply: tmp = path + '.migrate-tmp' with open(tmp, 'w', encoding='utf-8') as f: f.write(s.replace(old, new)) shutil.copystat(path, tmp) os.replace(tmp, path) # 原子替换,避免并发读者读到半截文件 print(f" [已替换 x{n}] {path}\n {old}\n -> {new}") else: print(f" [将替换 x{n}] {path}\n {old}\n -> {new}") PY } backup_file() { # 保留目录结构、扁平化文件名 local f="$1" [[ -f "$f" ]] || return 0 mkdir -p "$BK/files" local flat; flat="$(printf '%s' "$f" | sed 's#^/##; s#/#_#g')" cp -p "$f" "$BK/files/$flat" } # --- DB 工具 --------------------------------------------------------------- init_db_vars() { DB_URL=""; DB_USER=""; DB_PASS="" DB_HOST=""; DB_PORT=""; DB_NAME="" [[ -f "$BACKEND_ENV" ]] || return 1 read_envvar() { grep -E "^$1=" "$BACKEND_ENV" | head -1 | cut -d= -f2- \ | sed -e "s/^'//" -e "s/'$//" -e 's/^"//' -e 's/"$//' } DB_URL="$(read_envvar MYSQL_URL)"; DB_USER="$(read_envvar MYSQL_USERNAME)"; DB_PASS="$(read_envvar MYSQL_PASSWORD)" [[ -n "$DB_URL" && -n "$DB_USER" ]] || return 1 if [[ "$DB_URL" =~ jdbc:mysql://([^:/]+):([0-9]+)/([^?]+) ]]; then DB_HOST="${BASH_REMATCH[1]}"; DB_PORT="${BASH_REMATCH[2]}"; DB_NAME="${BASH_REMATCH[3]}" else return 1 fi return 0 } db_sql() { MYSQL_PWD="$DB_PASS" mysql -h"$DB_HOST" -P"$DB_PORT" -u"$DB_USER" "$DB_NAME" \ --default-character-set=utf8mb4 -N -B -e "$1"; } db_available() { command -v mysql >/dev/null 2>&1 && init_db_vars && db_sql "SELECT 1" >/dev/null 2>&1; } # 将将被修改的 DB 行导出为前像(REPLACE 语句;回滚:mysql "$DB_NAME" < "$BK/db-before.sql") db_dump_before() { local dump_bin dump_bin="$(command -v mysqldump || true)" if [[ -z "$dump_bin" && -x /usr/bin/mysqldump ]]; then dump_bin="/usr/bin/mysqldump"; fi [[ -n "$dump_bin" ]] || die "mysqldump 缺失,无法导出 DB 前像(P1-4 要求),请先安装 mysql-client" mkdir -p "$BK" : > "$BK/db-before.sql" # 注意:生产 DB 账号无 RELOAD 权限,不能加 --single-transaction(会 FLUSH TABLES 报错); # 用 --skip-lock-tables 保证不锁表,目标表均为低写入的管理配置表,前像一致性可接受。 local -a common=(-h"$DB_HOST" -P"$DB_PORT" -u"$DB_USER" --no-create-info --skip-extended-insert --replace --skip-triggers --skip-lock-tables --set-gtid-purged=OFF --default-character-set=utf8mb4 "$DB_NAME") MYSQL_PWD="$DB_PASS" "$dump_bin" "${common[@]}" agent_experiences \ --where="id IN (66,74,75,76)" >> "$BK/db-before.sql" MYSQL_PWD="$DB_PASS" "$dump_bin" "${common[@]}" scheduled_task \ --where="id IN (96,97)" >> "$BK/db-before.sql" MYSQL_PWD="$DB_PASS" "$dump_bin" "${common[@]}" session_workspace_rules \ --where="source_path='/opt/mao-data/workspace/feishu-chat/3/oc_67837a63395d608df8e766ba96919fe0/AGENTS.md'" >> "$BK/db-before.sql" } db_run_update() { # $1=描述 $2=SQL;同连接执行并打印 affected rows local desc="$1" sql="$2" out affected out="$(db_sql "$sql"$'\nSELECT ROW_COUNT();')" affected="$(printf '%s\n' "$out" | tail -1)" note "[$desc] affected=$affected" } # ============================================================================ # phase 1:数据面(备份 + 建结构 + 分发 + README + 删试验副本) # ============================================================================ phase1_actions() { # $1=apply(1/0) local apply="$1" head2 "phase1 · 数据面" note "备份目录:$BK(share-full.tgz + 后续文件级备份)" if [[ "$apply" == "1" ]]; then require_root mkdir -p "$BK" tar -czf "$BK/share-full.tgz" -C "$(dirname "$SHARE_DIR")" "$(basename "$SHARE_DIR")" note "已生成全量备份:$BK/share-full.tgz" fi note "mkdir -p $SHARE_DIR/agents/{${AGENTS[*]}}" if [[ "$apply" == "1" ]]; then for a in "${AGENTS[@]}"; do mkdir -p "$SHARE_DIR/agents/$a"; done fi copy_if_missing() { # $1 源 $2 目标 if [[ ! -e "$1" ]]; then note "[跳过·源不存在] $1"; return 0; fi if [[ -e "$2" ]]; then if diff -q "$1" "$2" >/dev/null 2>&1; then note "[已存在且一致] $2" else note "[已存在且不一致!保留现状,需人工确认] $2"; fi return 0 fi note "cp $1 -> $2" [[ "$apply" == "1" ]] && cp -p "$1" "$2" return 0 } local a for a in 1 3 4 5; do copy_if_missing "$SHARE_DIR/settings.xml" "$SHARE_DIR/agents/$a/settings.xml" done copy_if_missing "$SHARE_DIR/settings_t.xml" "$SHARE_DIR/agents/6/settings_t.xml" copy_if_missing_dir() { # $1 源目录 $2 目标目录 if [[ ! -d "$1" ]]; then note "[跳过·源不存在] $1"; return 0; fi if [[ -e "$2" ]]; then note "[已存在] $2(保留现状)"; return 0; fi local tmp="$2.migrate-tmp" note "cp -a $1 -> $2(先复制到临时目录,完成后原子 mv,避免半截副本)" if [[ "$apply" == "1" ]]; then rm -rf "$tmp" cp -a "$1" "$tmp" rm -rf "$tmp/__pycache__" # 不把字节码缓存带进新目录 mv "$tmp" "$2" fi return 0 } for a in "${AGENTS[@]}"; do copy_if_missing_dir "$SHARE_DIR/scripts" "$SHARE_DIR/agents/$a/scripts" done note "修正各副本内自引用:agents/{${AGENTS[*]}}/scripts/push_fast.sh 的 SHARE 路径 → 各自 scripts 目录" [[ "$apply" == "1" ]] || note "(apply 时执行)" if [[ "$apply" == "1" ]]; then for a in "${AGENTS[@]}"; do replace_in_file "$SHARE_DIR/agents/$a/scripts/push_fast.sh" \ "/opt/mao-data/share/scripts" "/opt/mao-data/share/agents/$a/scripts" 1 done fi local readme="$SHARE_DIR/README.md" if [[ -e "$readme" ]]; then note "[已存在] $readme"; else note "写 $readme(结构说明与投放指引)" if [[ "$apply" == "1" ]]; then cat > "$readme" <<'EOM' # /opt/mao-data/share 结构说明与投放指引(2026-11-09 起) 本目录已按 Agent 隔离,不再有全员共享的公共投放区。 ## 结构 - `agent-experience/{agentId}/`:团队经验库(平台管理;Agent 会话内经工作区 `团队经验/` 软链访问)。 - `agents/{agentId}/`:该 Agent 的私有区(脚本、配置、产物、投放品)。 ## 管理员/运维投放指引 - 投放:把文件放到 `agents/<目标AgentId>/`——目录级挂载,即时生效(无需重启/重建容器)。 - 多 Agent 需要同一文件时逐个复制(无公共区)。 - 备份请放 `/opt/mao-data/backup`(勿放任何用户挂载面,避免经用户级共享面泄露)。 ## 迁移说明(2026-11-09) - 原 `share/scripts` → `agents/{1,3,4,5,6}/scripts/`(每个 Agent 一份);`share/settings.xml` → `agents/{1,3,4,5}/`;`share/settings_t.xml` → `agents/6/`。 - `agent-experience/{agentId}/` 路径不变。 EOM fi fi head2 "phase1 · 删除试验副本(agent-experience/*/share)" for a in "${AGENTS[@]}"; do local victim="$SHARE_DIR/agent-experience/$a/share" if [[ -e "$victim" ]]; then note "rm -rf $victim" [[ "$apply" == "1" ]] && rm -rf "$victim" fi done return 0 } # ============================================================================ # phase 2:引用更新(DB + 文件清单) # ============================================================================ db_apply_updates() { db_run_update "agent_experiences#66 settings_t 路径" \ "UPDATE agent_experiences SET content = REPLACE(content, '/opt/mao-data/share/settings_t.xml', '/opt/mao-data/share/agents/6/settings_t.xml') WHERE id = 66 AND content LIKE '%/opt/mao-data/share/settings_t.xml%';" local pair id a for pair in "74:5" "75:1" "76:6" "94:7"; do id="${pair%%:*}"; a="${pair##*:}" db_run_update "agent_experiences#$id 共享文案" \ "UPDATE agent_experiences SET content = REPLACE(content, '有需要跨用户共享的文件一律放到 /opt/mao-data/share 目录下', '本 Agent 的文件一律放到 /opt/mao-data/share/agents/$a/ 目录下(share 已按 Agent 隔离,无跨 Agent 公共共享目录;旧路径已迁移,勿自行重建)') WHERE id = $id AND content LIKE '%有需要跨用户共享的文件一律放到 /opt/mao-data/share 目录下%';" done db_run_update "scheduled_task#96/#97 scripts 路径(历史行同步)" \ "UPDATE scheduled_task SET prompt = REPLACE(prompt, '/opt/mao-data/share/scripts/', '/opt/mao-data/share/agents/5/scripts/') WHERE id IN (96,97) AND prompt LIKE '%/opt/mao-data/share/scripts/%';" db_run_update "session_workspace_rules oc_6783 旧快照(删除后下轮 resolve 自动重建)" \ "DELETE FROM session_workspace_rules WHERE source_path = '/opt/mao-data/workspace/feishu-chat/3/oc_67837a63395d608df8e766ba96919fe0/AGENTS.md' AND rules_text LIKE '%/opt/mao-data/share/settings_t.xml%';" } phase2_actions() { # $1=apply(1/0) local apply="$1" head2 "phase2 · 引用更新" if [[ "$apply" == "1" ]]; then require_root local a for a in "${AGENTS[@]}"; do [[ -d "$SHARE_DIR/agents/$a/scripts" ]] || die "phase1 未完成(缺少 agents/$a/scripts),请先执行 apply --phase=1" done for a in 1 3 4 5; do [[ -f "$SHARE_DIR/agents/$a/settings.xml" ]] || die "phase1 未完成(缺少 agents/$a/settings.xml),请先执行 apply --phase=1" done [[ -f "$SHARE_DIR/agents/6/settings_t.xml" ]] || die "phase1 未完成(缺少 agents/6/settings_t.xml),请先执行 apply --phase=1" fi head2 "phase2 · DB 引用更新(agent_experiences #66/#74/#75/#76、scheduled_task #96/#97、快照 5 行)" if db_available; then note "当前值预览:" db_sql "SELECT id, LEFT(content, 200) FROM agent_experiences WHERE id IN (66,74,75,76) ORDER BY id;" | sed 's/^/ /' db_sql "SELECT id, LEFT(prompt, 160) FROM scheduled_task WHERE id IN (96,97) ORDER BY id;" | sed 's/^/ /' db_sql "SELECT session_id, workspace, source_path FROM session_workspace_rules WHERE rules_text LIKE '%/opt/mao-data/share/settings_t.xml%';" | sed 's/^/ /' if [[ "$apply" == "1" ]]; then head2 "phase2 · DB 前像导出" db_dump_before note "已导出:$BK/db-before.sql(REPLACE 语句;回滚:MYSQL_PWD=… mysql -h… $DB_NAME < $BK/db-before.sql)" db_apply_updates note "更新后校验(期望:旧串计数为 0):" db_sql "SELECT 'agent_experiences_old_share_text', COUNT(*) FROM agent_experiences WHERE content LIKE '%有需要跨用户共享的文件一律放到 /opt/mao-data/share 目录下%' UNION ALL SELECT 'agent_experiences_old_settings_t', COUNT(*) FROM agent_experiences WHERE content LIKE '%/opt/mao-data/share/settings_t.xml%' UNION ALL SELECT 'scheduled_task_old_scripts', COUNT(*) FROM scheduled_task WHERE prompt LIKE '%/opt/mao-data/share/scripts/%' UNION ALL SELECT 'session_workspace_rules_old_settings_t', COUNT(*) FROM session_workspace_rules WHERE rules_text LIKE '%/opt/mao-data/share/settings_t.xml%';" | sed 's/^/ /' else note "(apply 时执行上述 REPLACE/DELETE;命中 0 次即已是最新)" fi else if [[ "$apply" == "1" ]]; then die "DB 不可达或 mysql 缺失——phase2 必须更新 DB,请检查 --backend-env($BACKEND_ENV)或在可连 DB 的环境执行" fi note "(DB 不可达或 mysql 缺失——预演跳过 DB 预览;apply 时会强制要求 DB 可达)" fi head2 "phase2 · 文件引用更新(${#FILE_RULES[@]} 条规则)" local rule file rest old new n=0 local -a missing=() for rule in "${FILE_RULES[@]}"; do file="${rule%%||*}"; rest="${rule#*||}" old="${rest%%||*}"; new="${rest#*||}" if [[ ! -f "$file" ]]; then missing+=("$file") note "[缺失] $file ← 可能已被改名/合并,需人工核对" continue fi if [[ "$apply" == "1" ]] && grep -qF -- "$old" "$file" 2>/dev/null; then backup_file "$file" fi replace_in_file "$file" "$old" "$new" "$apply" done if [[ ${#missing[@]} -gt 0 ]]; then if [[ "$apply" == "1" && "$ALLOW_MISSING" -ne 1 ]]; then log " 以下规则目标文件缺失(${#missing[@]} 个):" printf ' %s\n' "${missing[@]}" die "phase2 中止:请核对改名后的目标文件并更新 FILE_RULES,确认可忽略后加 --allow-missing 重跑" fi note "WARN:${#missing[@]} 个规则目标文件缺失(plan 预演或 --allow-missing 放行);verify 残留扫描兜底" fi return 0 } # ============================================================================ # phase 3:清理旧残留(须部署并验证后执行) # ============================================================================ phase3_actions() { # $1=apply(1/0) local apply="$1" head2 "phase3 · 清理旧残留" log " 将删除:$SHARE_DIR/scripts/" log " $SHARE_DIR/settings.xml" log " $SHARE_DIR/settings_t.xml" log " 警告:执行后历史消息中的旧路径失效(已接受项)。前置条件:" log " ① 代码已部署(挂载已切换) ② verify --pre-phase3 通过(FAIL=0) ③ 已观察 ≥1 天" log " ④ 待删残留将先自动打包到 $BK/phase3-residual.tgz" if [[ "$apply" == "1" ]]; then require_root mkdir -p "$BK" local -a resid=() local x for x in scripts settings.xml settings_t.xml; do if [[ -e "$SHARE_DIR/$x" ]]; then resid+=("$x"); fi done if [[ ${#resid[@]} -gt 0 ]]; then tar -czf "$BK/phase3-residual.tgz" -C "$SHARE_DIR" "${resid[@]}" note "已备份待删残留(${resid[*]}):$BK/phase3-residual.tgz" else note "待删残留均不存在(可能已清理)" fi rm -rf "$SHARE_DIR/scripts" rm -f "$SHARE_DIR/settings.xml" "$SHARE_DIR/settings_t.xml" note "已删除。当前顶层:" ls -la "$SHARE_DIR" | sed 's/^/ /' fi return 0 } # ============================================================================ # verify:结构与残留校验 # ============================================================================ do_verify() { head2 "verify · 结构与残留(模式:$([[ "$VERIFY_MODE" == "post" ]] && echo post-phase3 || echo pre-phase3))" local OK=0 FAIL=0 WARN=0 pass() { note "[OK] $1"; OK=$((OK+1)); } fail() { note "[FAIL] $1"; FAIL=$((FAIL+1)); } soft() { # 待 phase2/phase3 收敛的项:pre 模式 WARN,post 模式 FAIL if [[ "$VERIFY_MODE" == "post" ]]; then fail "$1" else note "[WARN] $1(pre-phase3 模式不计 FAIL;--post-phase3 时计 FAIL)" WARN=$((WARN+1)) fi } check() { local desc="$1"; shift; if "$@" >/dev/null 2>&1; then pass "$desc"; else fail "$desc"; fi; } check "README.md 存在" test -f "$SHARE_DIR/README.md" local a for a in "${AGENTS[@]}"; do check "agents/$a 目录存在" test -d "$SHARE_DIR/agents/$a" check "agent-experience/$a 目录存在" test -d "$SHARE_DIR/agent-experience/$a" done for a in 1 3 4 5; do check "agents/$a/settings.xml 存在" test -f "$SHARE_DIR/agents/$a/settings.xml" done check "agents/6/settings_t.xml 存在" test -f "$SHARE_DIR/agents/6/settings_t.xml" for a in "${AGENTS[@]}"; do check "agents/$a/scripts/skill-cli-health-check.py 存在" test -f "$SHARE_DIR/agents/$a/scripts/skill-cli-health-check.py" done local leftovers rc if leftovers="$(find "$SHARE_DIR/agent-experience" -maxdepth 2 -name share -type d 2>/dev/null)"; then rc=0; else rc=$?; fi if [[ "$rc" -ne 0 ]]; then fail "试验副本清理检查(find 执行失败 rc=$rc)" elif [[ -z "$leftovers" ]]; then pass "试验副本已清除(agent-experience/*/share 不存在)" else fail "试验副本仍存在:$(printf '%s' "$leftovers" | tr '\n' ' ')" fi local pfbad="" pf for a in "${AGENTS[@]}"; do pf="$SHARE_DIR/agents/$a/scripts/push_fast.sh" if [[ ! -f "$pf" ]] || grep -q '/opt/mao-data/share/scripts' "$pf" 2>/dev/null; then pfbad+="agents/$a " fi done if [[ -z "$pfbad" ]]; then pass "副本内 push_fast.sh 自引用已更新(全 Agent 副本)" else fail "副本内 push_fast.sh 自引用未更新或缺失:$pfbad" fi # --- 旧路径残留扫描(phase2 的验收面)----------------------------------- residual_check() { # $1 描述;其余为 grep 目标(文件/目录,可含 grep 选项) local desc="$1"; shift local hits="" rc=1 n=0 if hits="$(grep -rn -F --exclude-dir=__pycache__ \ -e "/opt/mao-data/share/scripts" -e "/opt/mao-data/share/settings" \ -e "/opt/mao-data/share/tools" "$@" 2>/dev/null)"; then rc=0; else rc=$?; fi if [[ "$rc" -ge 2 ]]; then fail "$desc(扫描失败 rc=$rc)"; return 0; fi if [[ "$rc" -eq 0 ]]; then n="$(printf '%s\n' "$hits" | wc -l)" log " 命中 $n 处(前 20 行):" printf '%s\n' "$hits" | head -20 | sed 's/^/ /' soft "$desc:命中 $n 处旧路径" return 0 fi pass "$desc" return 0 } residual_check "经验库(agent-experience/**/*.md)无旧路径残留" \ --include='*.md' --exclude-dir=share "$SHARE_DIR/agent-experience" for a in "${AGENTS[@]}"; do if [[ -d "$SHARE_DIR/agents/$a/scripts" ]]; then residual_check "agents/$a/scripts 无旧路径残留" "$SHARE_DIR/agents/$a/scripts" else fail "agents/$a/scripts 无旧路径残留(目录不存在,phase1 未完成)" fi done local seen=$'\n' rule f x local -a rfiles=() rexist_extra=() rmissing=() for rule in "${FILE_RULES[@]}"; do f="${rule%%||*}" if [[ "$seen" != *$'\n'"$f"$'\n'* ]]; then seen+="$f"$'\n' rfiles+=("$f") fi done for f in "${rfiles[@]}"; do if [[ ! -f "$f" ]]; then rmissing+=("$f") elif [[ "$f" != "$SHARE_DIR/agent-experience/"* ]]; then rexist_extra+=("$f") fi done if [[ ${#rmissing[@]} -gt 0 ]]; then fail "规则目标文件缺失(${#rmissing[@]} 个,可能被改名/合并):$(printf '%s ' "${rmissing[@]}")" else pass "规则目标文件齐全(${#rfiles[@]} 个)" fi if [[ ${#rexist_extra[@]} -gt 0 ]]; then residual_check "规则目标文件(经验库外)无旧路径残留" "${rexist_extra[@]}" fi # --- phase3 待清理项(pre 模式 WARN)----------------------------------- local -a oldroot=() for x in scripts settings.xml settings_t.xml; do if [[ -e "$SHARE_DIR/$x" ]]; then oldroot+=("$x"); fi done if [[ ${#oldroot[@]} -eq 0 ]]; then pass "根目录旧残留已清理(phase3 后生效)" else soft "根目录仍有旧残留:${oldroot[*]}(phase3 删除)" fi # --- DB ---------------------------------------------------------------- if db_available; then db_count_check() { # $1 描述 $2 SQL local desc="$1" sql="$2" c c="$(db_sql "$sql" | head -1)" if [[ "$c" == "0" ]]; then pass "$desc" else log " $desc:当前 $c 行" soft "$desc(残留 $c 行)" fi } db_count_check "DB scheduled_task 无 share/scripts 旧路径" \ "SELECT COUNT(*) FROM scheduled_task WHERE prompt LIKE '%/opt/mao-data/share/scripts/%';" db_count_check "DB agent_experiences 无旧共享文案" \ "SELECT COUNT(*) FROM agent_experiences WHERE content LIKE '%有需要跨用户共享的文件一律放到 /opt/mao-data/share 目录下%';" db_count_check "DB agent_experiences 无 settings_t 旧路径" \ "SELECT COUNT(*) FROM agent_experiences WHERE content LIKE '%/opt/mao-data/share/settings_t.xml%';" db_count_check "DB session_workspace_rules 无 settings_t 旧快照" \ "SELECT COUNT(*) FROM session_workspace_rules WHERE rules_text LIKE '%/opt/mao-data/share/settings_t.xml%';" else if [[ "$ALLOW_NO_DB" -eq 1 ]]; then note "[SKIP] DB 校验(--no-db 显式跳过)" else fail "DB 不可达(如确需跳过请显式加 --no-db)" fi fi head2 "verify · 容器级验证命令(手动执行,以 user36/agent5 为例)" cat <<'EOM' docker exec mao-user-36-agent-5 ls /opt/mao-data/share/agent-experience/ # 期望:仅 5 docker exec mao-user-36-agent-5 ls /opt/mao-data/share/agents/ # 期望:仅 5 docker exec mao-user-36-agent-5 cat /opt/mao-data/share/agent-experience/6/x 2>&1 # 期望:ENOENT docker exec mao-user-36-agent-5 sh -c 'echo x > /opt/mao-data/share/agents/5/.t && rm /opt/mao-data/share/agents/5/.t' # 期望:成功 EOM head2 "verify · 汇总(模式:$([[ "$VERIFY_MODE" == "post" ]] && echo post-phase3 || echo pre-phase3))" log " OK=$OK FAIL=$FAIL WARN=$WARN" if [[ "$FAIL" -eq 0 ]]; then log " 结论:PASS"; return 0 fi log " 结论:FAIL"; return 1 } # ============================================================================ # 入口 # ============================================================================ apply_phase() { local n="$1" log "========== 预演(phase$n)==========" "phase${n}_actions" 0 confirm log "========== 执行(phase$n)==========" "phase${n}_actions" 1 return 0 } case "$MODE" in plan) log "== 只读预演:share=$SHARE_DIR(不会改动任何文件/数据)==" phase1_actions 0 phase2_actions 0 phase3_actions 0 log "" log "预演完成。执行顺序建议:apply --phase=1 → apply --phase=2 → 部署代码 → verify --pre-phase3 + 回归" log " → 观察 ≥1 天 → apply --phase=3 → verify --post-phase3" ;; apply) apply_phase "$PHASE" ;; verify) do_verify ;; esac