Ctrl K
ComunidadGuías…

scripts/migrate-share-per-agent.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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>||<old>||<new> ------------------------------------
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/<uid>/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/<uid>/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