Files
dsh_shenxian/dsh-server-docs/scripts/handoff-guard.sh
T
admin 5ad755116e chore(docs): 文档库并入代码仓(R4 选 a)+ 索引/台账跟进
1) dsh-server-docs/ 从工作区(原 E:\...\aliyun-dsh-server\dsh-server-docs)**整体并入本仓**,
   保留目录名 ⇒ 仓库内 dsh-server-docs/... 的相对引用天然继续有效;旧目录(含其 .git)已归档到
   工作区 _中间产物_待清理/,未随本提交带入。
2) .gitattributes:新增 `dsh-server-docs/** -text` —— 原文档库是 `* -text` + autocrlf=false,
   必须保持纯 LF,否则会被本仓的 CRLF 规则翻掉。
3) 活引用里的绝对路径已全部改到新位置(docs 的 INDEX / README / scripts / skills + 用户级 skills
   + ~/.workbuddy/settings.json 的 hooks);历史档案(04-调整方案/、archive/)按「只增不改」未动。
   ⚠️ hooks 路径改动需「完全重启会话」才生效(配置是会话启动快照)。
4) 交接单/T08:新增 §16「生产整体切换执行记录」(形态 / 落地动作 / **4 个只有真上线才暴露的真 bug** /
   验收证据 / 回滚命令 / 残留项);台账 T08 行 → 已完成并归档;03-路线图 §二 登记 T08 收尾项。
5) 统一称谓:**「本机」只指跑 WorkBuddy 的开发机**,47 / 106 一律写「远程服务器」。
2026-09-15 18:47:13 +08:00

293 lines
16 KiB
Bash
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# handoff-guard.sh — 开工 / 推送前的「并行冲突预检」(只读;--claim 除外,它只建一个锁目录)
#
# 为什么需要它:本库由**多个 AI 会话并行**读写,而「先读后改 / Edit 增量 / 改完 commit」这类
# 约定全部依赖“人记得做”。本脚本把判据变成**一条命令 + 退出码**,不靠记忆。
#
# 关键设计:**mtime 只作提示、不作判定**(它分不清“谁改的”)。真正的判定来自四处:
# ① 全局执行锁(交接单/.exec-lock)—— **一粗**:同一时刻只允许一个执行会话动「文档/代码/服务器」
# ① 单级占用锁(交接单/.doing-<单号>)—— **一细**:这个单归谁做(供台账 / 接管使用)
# ② 越界改动(不在我声明清单里的未提交文件)—— 推送前检查,防“顺手重放别人的半成品”
# ④ 双端一致性(docs-sync-check.sh)—— 推送前检查,防“幽灵文件”(推回了别人已移走的文件)
#
# 两级锁的关系:**先抢全局锁 → 再占单级锁**;释放时**先放单级、再放全局**。
# 单级锁允许“两个会话各做一单”(冲突域不重叠时);全局锁则彻底禁止并行执行。
# ⇒ 本库现状(共享入口文件多 + 要动服务器)建议**默认只跑一个执行会话**,即始终持全局锁。
#
# 用法:
# bash scripts/handoff-guard.sh --claim-exec "exec-session-B" # 【第一步】抢全局执行锁
# bash scripts/handoff-guard.sh --claim T03 "exec-session-B" # 【第二步】占单级锁
# bash scripts/handoff-guard.sh --release T03 # 完工:先放单级
# bash scripts/handoff-guard.sh --release-exec # 再放全局
# bash scripts/handoff-guard.sh # 看全局状态(信息模式)
# ME="exec-session-B" MINE="交接单/T03-*.md" bash scripts/handoff-guard.sh T03 # 开工检查(严格)
# ME="exec-session-B" MINE="..." PUSH=1 bash scripts/handoff-guard.sh T03 # 推送前检查
#
# 环境变量:ME(我是谁 —— 用来判断锁是不是自己的;不设则一律按“别人的锁”处理)
# MINE(我本次要改的文件,空格分隔,支持 * 通配)
# PUSH=1 启用推送前硬判定(④ 幽灵文件)
# GUARD_WINDOW(分钟,默认 30)|SKIP_SYNC=1 跳过 ④
# 退出码:0 = 放行;1 = 命中硬冲突/硬判定;2 = 环境错误
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT" || exit 2
LOCKDIR="$ROOT/交接单/.doing-"
LOCKEXEC="$ROOT/交接单/.exec-lock"
ME="${ME:-}"
# ── 全局执行锁:占位 / 释放 ──────────────────────────────
if [ "${1:-}" = "--claim-exec" ]; then
OWNER="${2:-${ME:-$(whoami)}}"
if mkdir "$LOCKEXEC" 2>/dev/null; then
printf '%s\n开始:%s\n在做:%s\n' "$OWNER" "$(date '+%m-%d %H:%M')" "${3:-(未声明单号)}" > "$LOCKEXEC/OWNER"
echo "✓ 已持全局执行锁($OWNER)"
echo " ⛔ **锁的生命周期 = 任务的生命周期**(2026-09-14 用户明令):执行完成 → 必须 \`--release-exec\` 才算完成;"
echo " 禁止抢锁做一半、不解锁就结束回合/会话(本库无心跳,带锁结束 = 把所有人挡在门外)。中途要停 ⇒ 先释放再停。"
exit 0
fi
echo "✗ 抢锁失败:已有执行会话在跑 ——
占用者:$(sed -n '1p' "$LOCKEXEC/OWNER" 2>/dev/null || echo '?') $(sed -n '2p' "$LOCKEXEC/OWNER" 2>/dev/null)
$(sed -n '3p' "$LOCKEXEC/OWNER" 2>/dev/null)
→ **停手**:等它做完(它会 --release-exec)。⛔ **不得人工删锁、不得接管**(**R9**,用户 2026-09-12 明令)——
抢不到锁 = **停手 + 报告用户**;**锁的处置权只属于用户本人**" >&2
exit 1
fi
if [ "${1:-}" = "--release-exec" ]; then
rm -rf "$LOCKEXEC" && echo "✓ 已释放全局执行锁" || { echo "✗ 释放失败" >&2; exit 1; }
exit 0
fi
# ── 占位 / 释放 ─────────────────────────────────────────
if [ "${1:-}" = "--claim" ]; then
T="${2:-}"; OWNER="${3:-$(whoami)@$(date +%H:%M)}"
[ -n "$T" ] || { echo "用法:--claim <单号> [占用者]" >&2; exit 2; }
if mkdir "$LOCKDIR$T" 2>/dev/null; then
printf '%s\n' "$OWNER" > "$LOCKDIR$T/OWNER"
echo "✓ 已占位:交接单/.doing-$T($OWNER)—— 完工请 --release $T"
exit 0
fi
echo "✗ 占位失败:交接单/.doing-$T 已存在(占用者 $(cat "$LOCKDIR$T/OWNER" 2>/dev/null || echo '?')) → 停手" >&2
exit 1
fi
if [ "${1:-}" = "--release" ]; then
T="${2:-}"; [ -n "$T" ] || { echo "用法:--release <单号>" >&2; exit 2; }
rm -rf "$LOCKDIR$T" && echo "✓ 已释放:交接单/.doing-$T" || { echo "✗ 释放失败" >&2; exit 1; }
exit 0
fi
# ── 预检 ────────────────────────────────────────────────
MY_TASK="${1:-}"
WINDOW="${GUARD_WINDOW:-30}"
MINE="${MINE:-}"
PUSH="${PUSH:-0}"
VERDICT=0
HARD=() # 硬失败原因
[ -n "$MINE" ] && STRICT=1 || STRICT=0
echo "=================================================================="
echo "并行冲突预检 root=$ROOT"
echo "本次任务 = ${MY_TASK:-(未声明)}"
echo "我是谁(ME) = ${ME:-(未声明 → 任何锁都按"别人的"处理)}"
echo "我声明要改 = ${MINE:-(未声明 → 仅信息模式)}"
echo "模式 = $([ "$PUSH" = "1" ] && echo 推送前检查 || echo 开工检查)"
echo "=================================================================="
in_mine() { [ -z "$MINE" ] && return 1; for p in $MINE; do case "$1" in $p) return 0;; esac; done; return 1; }
# ── ① 占用锁(硬判定)────────────────────────────────────
echo
echo "【1】占用锁(交接单/.doing-*)"
shopt -s nullglob
LOCKS=("$LOCKDIR"*)
if [ ${#LOCKS[@]} -eq 0 ]; then
echo " ✓ 无人占用 —— ⚠ 这不是「可以开工」,是「**你快去抢**」:"
echo " 开工前先:ME=\"<你的会话名>\" bash scripts/handoff-guard.sh --claim <单号> \"<你的会话名>\""
else
for L in "${LOCKS[@]}"; do
n="$(basename "$L")"; o="$(cat "$L/OWNER" 2>/dev/null || echo '(未写 OWNER)')"
if [ -n "$MY_TASK" ] && [ "$n" = ".doing-$MY_TASK" ]; then
echo " · $n ← 你自己占的($o)"
else
echo " ⚠ $n 被占用:$o → 冲突域重叠就别开工"
VERDICT=1; HARD+=("① 别的会话持有占用锁 $n($o)")
fi
done
fi
# ── ①b 锁 ↔ 台账一致性(2026-09-12 新增)────────────────
# 实证:T03 10:14 就被占了,但台账那行直到 12:59 还写着「待执行」→ 别人看台账会以为**没人做**,
# 于是可能重复开工(这不是文件写冲突,而是"状态不可见"造成的重复劳动)。
if [ ${#LOCKS[@]} -gt 0 ]; then
for L in "${LOCKS[@]}"; do
n="$(basename "$L")"; T="${n#.doing-}"
row="$(grep -m1 "^| \`$T-" 交接单/README.md 2>/dev/null || true)"
if [ -z "$row" ]; then
echo " ⚠ $T 有占用锁,但 \`交接单/README.md §一\` 里**没有这一行** → 补上(新增单)"
VERDICT=1; HARD+=("①b $T 有锁但台账缺行")
elif printf '%s' "$row" | grep -qE "执行中|已完成|已归档"; then
echo " · $T 台账已标「执行中/已完成」✓"
else
echo " ⚠ $T 已被占用($n),但台账那行仍写作「$(printf '%s' "$row" | awk -F'|' '{print $3}' | tr -d ' ')」"
echo " → **台账滞后 = 别人可能重复开工**:立刻把该行状态改成「🔄 执行中」"
VERDICT=1; HARD+=("①b $T 有锁但台账未标「执行中」(重复开工风险)")
fi
done
fi
# ── ①c 全局执行锁(2026-09-12 新增:同一时刻只允许一个执行会话)────
echo
echo "【1c】全局执行锁(交接单/.exec-lock)"
if [ ! -d "$LOCKEXEC" ]; then
echo " ✓ 无全局锁 —— ⚠️ 这**不是「可以开工」,是「你快去抢」**:"
echo " 开工前必须先:bash scripts/handoff-guard.sh --claim-exec \"<你的会话名>\""
echo " (「环境干净」≠「没人动过」;抢锁是原子的,抢到才是你的开工许可)"
else
O1="$(sed -n '1p' "$LOCKEXEC/OWNER" 2>/dev/null)"
O2="$(sed -n '2p' "$LOCKEXEC/OWNER" 2>/dev/null)"
O3="$(sed -n '3p' "$LOCKEXEC/OWNER" 2>/dev/null)"
if [ -n "$ME" ] && [ "$O1" = "$ME" ]; then
echo " · 全局锁是**你自己**持有的($ME)—— 完工记得 --release-exec"
else
echo " ⚠ 全局锁被占用:$O1 $O2"
[ -n "$O3" ] && echo " $O3"
echo " → **同一时刻只允许一个执行会话**:要动「文档 / 代码 / 服务器」就先等它释放;"
echo " ⛔ **不得人工删锁 / 不得接管**(R9):锁只能由持有者自己 --release-exec ——"
echo " 抢不到 = 停手 + 报告用户;读 OWNER 仅用于「用户已点头、且用户自己撤锁之后」的续做"
VERDICT=1; HARD+=("①c 别的会话持有全局执行锁($O1)→ 不允许并行执行")
fi
fi
# ── ①d 服务器侧操作锁(2026-09-12 新增,T04):平台高危操作的跨会话互斥 ────
echo
echo "【1d】服务器侧操作锁(/opt/dsh/state/.op-lock)"
if [ "${SKIP_OPLOCK:-0}" = "1" ]; then
echo " · 已跳过(SKIP_OPLOCK=1)"
else
OPLOCK_OUT="$(ssh -o BatchMode=yes -o ConnectTimeout=8 "${OP_LOCK_REMOTE:-bt-server}" \
"if [ -d /opt/dsh/state/.op-lock ]; then ls -1 /opt/dsh/state/.op-lock 2>/dev/null | grep -v '^README\$' || true; else echo __NOLOCKDIR__; fi" 2>/dev/null)"; OPLOCK_RC=$?
if [ "$OPLOCK_RC" -ne 0 ]; then
echo " · 服务器不可达(离线)→ **只提示、不失败**;但要动线上时须先恢复可见性再动手"
elif [ "$OPLOCK_OUT" = "__NOLOCKDIR__" ]; then
echo " ⚠ 锁根目录不存在:/opt/dsh/state/.op-lock(T04 应已建立 → 需复查)"
VERDICT=1; HARD+=("①d 服务器侧锁根目录缺失")
elif [ -z "$OPLOCK_OUT" ]; then
echo " ✓ 无平台操作锁(此刻没有会话在动线上)"
else
echo " ⚠ 有会话正在动线上:"
for L in $OPLOCK_OUT; do
echo " 🔴 $L"
ssh -o BatchMode=yes -o ConnectTimeout=8 "${OP_LOCK_REMOTE:-bt-server}" \
"sed 's/^/ /' '/opt/dsh/state/.op-lock/$L/OWNER' 2>/dev/null" 2>/dev/null || true
done
echo " → 凡「重启 / drain / 改实例 env·quota / 批量铺插件 / 改 nginx·nft·证书」类操作,"
echo " 开工前必须先占位(bash scripts/op-lock.sh claim <操作名> \"<影响面>\");"
echo " 占位失败 = 有会话在动线上 → **停手**。"
VERDICT=1; HARD+=("①d 有会话持有服务器侧操作锁($OPLOCK_OUT)")
fi
fi
# ── ② 越界改动(推送前硬判定)────────────────────────────
echo
echo "【2】未提交改动(含未跟踪)"
CHANGED="$(git -c core.quotepath=false status --short 2>/dev/null | awk '{print $NF}' | grep -vE '^交接单/\.(doing-|exec-lock)' || true)"
OUTSIDE=(); INSIDE=()
if [ -z "$CHANGED" ]; then
echo " ✓ 工作区干净"
else
while IFS= read -r f; do
[ -z "$f" ] && continue
if in_mine "$f"; then INSIDE+=("$f"); else OUTSIDE+=("$f"); fi
done <<< "$CHANGED"
echo " · 我声明的:${#INSIDE[@]} 个"
if [ ${#OUTSIDE[@]} -eq 0 ]; then
echo " ✓ 无越界改动(没有别人的半成品混在里面)"
else
echo " ⚠ 越界(别人的/我没声明):${#OUTSIDE[@]} 个"
for f in "${OUTSIDE[@]:0:6}"; do echo " $f"; done
[ ${#OUTSIDE[@]} -gt 6 ] && echo " …还有 $(( ${#OUTSIDE[@]} - 6 )) 个"
echo " → **推送时只能 scp 自己声明的文件,切勿 'git add -A'**(本项不构成硬失败;"
echo " 真正的推送硬判定在【4】——那里能精确看出「你正要推什么」)"
fi
fi
# ── ③ 热点提示(仅提示,不作判定)────────────────────────
echo
echo "【3】近 $WINDOW 分钟被改动的文件(**仅提示**:mtime 分不清谁改的)"
HOT="$(find . -type f -mmin "-$WINDOW" \
-not -path './.git/*' -not -path '*/node_modules/*' -not -name '*.bak*' \
-not -path './交接单/.doing-*' -not -path './交接单/.exec-lock*' 2>/dev/null | sed 's|^\./||' | LC_ALL=C sort)"
if [ -z "$HOT" ]; then
echo " ✓ 无(这块是「冷」的)"
else
HIT=(); OTHER=""
while IFS= read -r f; do
[ -z "$f" ] && continue
if in_mine "$f"; then HIT+=("$f"); else OTHER="$OTHER$f"$'\n'; fi
done <<< "$HOT"
if [ ${#HIT[@]} -gt 0 ]; then
echo " ⚠ **我的目标文件近期被改动过**(可能是你自己,也可能是别人)→ 改前务必先读最新内容:"
for f in "${HIT[@]}"; do echo " $f"; done
else
echo " · 我的目标文件均未被近期改动"
fi
echo " · 其它近期被改动的文件:$(printf '%s' "$OTHER" | grep -c . || true) 个(与你无关,仅供感知全库热度)"
[ "$WINDOW" -gt 10 ] && echo " 想更锐利:GUARD_WINDOW=10 再跑一次"
fi
# ── ④ 双端一致性(推送前硬判定)──────────────────────────
echo
echo "【4】双端一致性(docs-sync-check.sh)"
if [ "${SKIP_SYNC:-0}" = "1" ]; then
echo " (SKIP_SYNC=1,跳过)"
elif [ -f scripts/docs-sync-check.sh ]; then
# 全量对账耗时(2026-09-12 实测):优化前 **2m15s**(Git Bash 逐文件 spawn md5sum → 曾被当成"挂死"),
# 优化后 **≈28s**。这里再加硬超时兜底,避免对账自身卡住时把整个 guard 拖死;急用时 `SKIP_SYNC=1` 跳过。
if command -v timeout >/dev/null 2>&1; then
SYNC="$(timeout 300 bash scripts/docs-sync-check.sh 2>/dev/null)"
else
SYNC="$(bash scripts/docs-sync-check.sh 2>/dev/null)"
fi
printf '%s\n' "$SYNC" | tail -6
# 关键:对账结果里的「仅本地(待推送)」= 一次朴素推送**实际会推上去**的东西。
# 其中只要有一个「不在我声明清单里」,就是幽灵文件信号(2026-09-12 事故:把已被对方归档的
# T02 又推回服务器)→ 推送前硬失败。
ONLYL="$(printf '%s\n' "$SYNC" | sed -n 's/.*仅本地(待推送) //p')"
if [ -n "$ONLYL" ]; then
GHOST=(); GOOD=0
while IFS= read -r f; do
[ -z "$f" ] && continue
if in_mine "$f"; then GOOD=$((GOOD+1)); else GHOST+=("$f"); fi
done <<< "$ONLYL"
echo " · 仅本地(= 一次朴素推送会推上去的):$GOOD 个已声明 + ${#GHOST[@]} 个未声明"
if [ ${#GHOST[@]} -gt 0 ]; then
echo " ⚠ 未声明却「仅本地」——**幽灵文件风险**:"
for f in "${GHOST[@]}"; do echo " $f"; done
echo " → 它可能是别人**刚归档/移走**的文件(你今天就是这么推回去的)"
if [ "$PUSH" = "1" ]; then
VERDICT=1; HARD+=("④ 有 ${#GHOST[@]} 个「仅本地」文件不在你的推送清单里 → 停手核实后再推")
else
echo " (开工阶段仅提示;推送前请带 PUSH=1 复跑)"
fi
fi
fi
else
echo " (未找到 scripts/docs-sync-check.sh)"
fi
# ── 结论 ────────────────────────────────────────────────
echo
echo "=================================================================="
if [ "$VERDICT" -ne 0 ]; then
echo "结论:**不可放行** ——"
for r in "${HARD[@]}"; do echo " $r"; done
elif [ "$STRICT" -eq 0 ]; then
echo "结论:未声明改动清单 → 仅为信息输出,**不构成放行依据**(开工请带 MINE=\"...\")"
echo " ⚠️ 且「无锁」不等于「可以开工」—— 开工前必须先 --claim-exec 抢锁(见上方【1c】)"
echo " (2026-09-12 实证:把「无锁」读成「可以动手」,导致两个会话同时改库)"
else
echo "结论:未命中硬冲突 → 可以继续(仍须遵守:Edit 增量 / 改前先读 / 只推自己的文件)"
fi
exit "$VERDICT"