Files
workbuddy_skills/session-mechanism/scripts/lock/handoff-guard.sh
T

622 lines
34 KiB
Bash
Raw Normal View History

#!/usr/bin/env bash
# handoff-guard.sh — 开工 / 推送前的「并行冲突预检」(只读;--claim 除外,它只建一个锁目录)
#
# 为什么需要它:本库由**多个 AI 会话并行**读写,而「先读后改 / Edit 增量 / 改完 commit」这类
# 约定全部依赖“人记得做”。本脚本把判据变成**一条命令 + 退出码**,不靠记忆。
#
# 关键设计:**mtime 只作提示、不作判定**(它分不清“谁改的”)。真正的判定来自四处:
# ① 全局执行锁(05-交接单/.exec-lock)—— **一粗**:同一时刻只允许一个任务会话动「文档/代码/服务器」
# ① 单级占用锁(05-交接单/.doing-<单号>)—— **一细**:这个单归谁做(供台账 / 接管使用)
# ② 越界改动(不在我声明清单里的未提交文件)—— 推送前检查,防“顺手重放别人的半成品”
# ④ 双端一致性(docs-sync-check.sh)—— 推送前检查,防“幽灵文件”(推回了别人已移走的文件)
#
# 两级锁的关系:**先抢全局锁 → 再占单级锁**;释放时**先放单级、再放全局**。
# 单级锁允许“两个会话各做一单”(冲突域不重叠时);全局锁则彻底禁止并行执行。
# ⇒ 本库现状(共享入口文件多 + 要动服务器)建议**默认只跑一个任务会话**,即始终持全局锁。
#
# 用法:
# bash 07-scripts/handoff-guard.sh --claim-exec "exec-session-B" # 【第一步】抢全局执行锁
# bash 07-scripts/handoff-guard.sh --claim T03 "exec-session-B" # 【第二步】占单级锁
# bash 07-scripts/handoff-guard.sh --release T03 # 完工:先放单级
# bash 07-scripts/handoff-guard.sh --release-exec # 再放全局(⚠️ 必须带会话名,见下)
# # 正确:ME="<会话名>" bash … --release-exec
# # 或:bash … --release-exec "<会话名>"
# # ⛔ 不带名字 ⇒ 拒绝释放(R9,2026-09-25 修)
# # 域锁侧:--claim-exec <会话> --domains <域…> / 同姿势 --release-exec <会话>
# bash 07-scripts/handoff-guard.sh # 看全局状态(信息模式)
# ME="exec-session-B" MINE="05-交接单/T03-*.md" bash 07-scripts/handoff-guard.sh T03 # 开工检查(严格)
# ME="exec-session-B" MINE="..." PUSH=1 bash 07-scripts/handoff-guard.sh T03 # 推送前检查
#
# 环境变量:ME(我是谁 —— 用来判断锁是不是自己的;不设则一律按“别人的锁”处理)
# MINE(我本次要改的文件,空格分隔,支持 * 通配)
# PUSH=1 启用推送前硬判定(④ 幽灵文件)
# GUARD_WINDOW(分钟,默认 30)|SKIP_SYNC=1 跳过 ④
# 退出码:0 = 放行;1 = 命中硬冲突/硬判定;2 = 环境错误
set -uo pipefail
# [session-mechanism] roots.env 外置(由 install.py 生成;缺失则回落到按位置推导)
_SM_HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_SM_PKG="$(cd "$_SM_HERE/../.." && pwd)"
[ -f "$_SM_PKG/roots.env" ] && . "$_SM_PKG/roots.env"
ROOT="${DSH_DOCS_ROOT:-$(cd "$_SM_HERE/.." && pwd)}"
cd "$ROOT" || exit 2
LOCKDIR="$ROOT/05-交接单/.doing-"
LOCKEXEC="$ROOT/05-交接单/.exec-lock"
MELOCK="$ROOT/05-交接单/.me-lock"
ME="${ME:-}"
# ══════════════════════════════════════════════════════════════════════════
# 域锁(2026-09-22 加 · 用户令「多个会话并行开发,全局锁导致无法并行」)
#
# 要害:`.exec-lock` 是**布尔量**(存在/不存在),不携带"我占了哪些资源"⇒ 判不出交集
# ⇒ 只能全有全无。域锁让锁**携带资源声明**,于是能算交集。
#
# 三类资源 × 三种持锁时长:
# 域锁 `--claim-exec <会话> --domains <域...>` 全程持有(层①/②/③ 不动 export 签名)
# 骨架锁 `--claim-skeleton <资源名>` 秒级(层④/迁移号/挂载点/package.json)
# 发布锁 `--claim-publish` 秒级(commit/push/scp/npm run build)
#
# 兼容:`.exec-lock` 分支**原样保留** ⇒ 未升级的会话照旧可用(hook 是启动时快照)。
# ══════════════════════════════════════════════════════════════════════════
LOCKSROOT="$ROOT/05-交接单/.locks"
GATELOCK="$LOCKSROOT/.gate" # 秒级临界区:算交集时必须串行,否则两个会话可能同时判定"无冲突"
# 域字符串规范化 ⇒ 域键。**必须与 lock-guard-hook.py 的 domain_key() 同规则**,
# 否则 shell 侧写进去的键和 Python 侧算出来的键对不上 ⇒ 域锁静默失效(假绿)。
# 规则:在路径里找第一个「已知根段」(src/web/07-scripts/08-skills/... 或工作区目录名),
# 取它和它后一段作域键;找不到就取末两段。
# ⚠️ 顺序有意义:**先长后短**,与 lock-guard-hook.py 的 _DOMAIN_SEGS 逐字一致。
# `dsh-server-docs/scripts` 必须锚成 `dsh-server-docs/scripts`(不是 `07-scripts/...`)
_ANCHOR_SEGS="dsh-server-docs ai1net-dsh-server aliyun-dsh-server src poc web test docs scripts skills 交接单"
norm_domain() {
d="${1//,/ }"
for one in $d; do
one="${one//\\//}"; one="${one%/}"
# 逐段找**第一个**锚点段(用 awk 保证取"首个",与 Python 的 for-seg-in-_DOMAIN_SEGS 一致)
_key="$(printf '%s' "$one" | awk -v anchors="$_ANCHOR_SEGS" '
BEGIN { n = split(anchors, A, " ") }
{ m = split($0, P, "/"); hit = 0
for (i = 1; i <= m && !hit; i++)
for (j = 1; j <= n; j++)
if (tolower(P[i]) == A[j]) { print A[j] "/" P[i+1]; hit = 1; break }
if (!hit) print (m >= 2 ? P[m-1] "/" P[m] : $0)
}')"
printf '%s\n' "$_key" | tr 'A-Z' 'a-z'
done
}
# 列出所有别人的域(排除自己的锁)
# ⚠️ 判定必须与 lock-guard-hook.py 的 _is_mine() 同规则:**会话名 与 session_id 同时吻合** 才算自己。
# 只比会话名 ⇒ 自己抢第二把域锁时会被自己挡住(2026-09-22 实测踩过,报"冲突域被占用"且占用者=自己)。
# 只比 session_id ⇒ 同进程复用时会假绿(同进程里 CODEBUDDY_SESSION_ID 恒为真实值)。
_my_owner() {
# OWNER 文件行序:1=会话名|2=开始:|3=会话:<session_id>|4=ME:<会话名>
_n="$(sed -n '1p' "$1/OWNER" 2>/dev/null || echo '')"
_id="$(sed -n '3p' "$1/OWNER" 2>/dev/null | sed 's/^会话://' || echo '')"
if [ -n "${CODEBUDDY_SESSION_ID:-}" ]; then
[ "$_n" = "${OWNER:-}" ] && [ "$_id" = "$CODEBUDDY_SESSION_ID" ] && return 0
return 1
fi
# 无 session_id(人工跑)⇒ 退回只比会话名
[ -n "${OWNER:-}" ] && [ "$_n" = "$OWNER" ] && return 0
[ -n "$ME" ] && [ "$_n" = "$ME" ] && return 0
return 1
}
others_domains() {
[ -d "$LOCKSROOT" ] || return 0
for L in "$LOCKSROOT"/*; do
[ -d "$L" ] || continue
[ "$(basename "$L")" = ".gate" ] && continue
[ "$(basename "$L")" = ".migrations" ] && continue
_my_owner "$L" && continue
o="$(sed -n '1p' "$L/OWNER" 2>/dev/null || echo '?')"
if [ -f "$L/DOMAINS" ]; then
while IFS= read -r d; do
[ -n "$d" ] && printf '%s\t%s\n' "$d" "$o"
done < "$L/DOMAINS"
fi
done
}
if [ "${1:-}" = "--claim-exec" ]; then
OWNER="${2:-${ME:-$(whoami)}}"
shift 2 2>/dev/null || shift 1 2>/dev/null || true
DOMAINS=()
# ★ 统一清理守卫(2026-09-22 实测教训):
# bash 的 trap 是**单一槽位** —— 先后 trap 两次,后者**覆盖**前者。
# 旧写法 "临时文件一个 trap + .gate 一个 trap" ⇒ 后注册的把前一个冲掉 ⇒ 必然漏一个。
# 故改成**同一个函数清两样**,全流程只注册一次。
# ⚠️ 更关键的坑:`.gate` 可能**是别人的**(临界区被别人占着,我们在 while 里等)。
# 若无脑 rmdir ⇒ **把别人的临界区删掉** ⇒ 破坏互斥(R9 精神:⛔ 不碰别人的锁)。
# ⇒ 守卫只在"**确实由本次调用创建了 .gate**"时才删它(_GATE_MINE 标志)。
_DOMTMP="$ROOT/.domains.tmp.$$"
_GATE_MINE=0
_cleanup_guard() {
rm -f "$_DOMTMP" 2>/dev/null || true
[ "$_GATE_MINE" = 1 ] && { rmdir "$GATELOCK" 2>/dev/null || true; }
}
trap _cleanup_guard EXIT INT TERM HUP
while [ $# -gt 0 ]; do
case "$1" in
--domains) shift; while [ $# -gt 0 ] && [ "${1#--}" = "$1" ]; do
# ⚠️ 不用 mktemp:本机 mktemp 出 MSYS 路径(/c/...),被当相对路径 ⇒ 落到仓库里
norm_domain "$1" > "$_DOMTMP"
while IFS= read -r _d; do [ -n "$_d" ] && DOMAINS+=("$_d"); done < "$_DOMTMP"
shift; done;;
*) shift;;
esac
done
rm -f "$_DOMTMP" 2>/dev/null || true
if [ ${#DOMAINS[@]} -eq 0 ]; then
# 无域声明 ⇒ 退化为旧行为:全局独占(⛔ 不静默放宽)
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 " 要用域锁并行:--claim-exec \"$OWNER\" --domains <域...>"
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
# ── 有域声明 ⇒ 域锁路径 ──
mkdir -p "$LOCKSROOT" 2>/dev/null
# 旧全局锁仍在 ⇒ 尊重它(兼容期:在跑的会话用旧 hook)
if [ -d "$LOCKEXEC" ]; then
O1="$(sed -n '1p' "$LOCKEXEC/OWNER" 2>/dev/null)"
if [ "$O1" != "$OWNER" ]; then
echo "✗ 抢域锁失败:**旧的全局锁**仍被占($O1)—— 它未升级到域锁,仍是全局独占。" >&2
echo " → 停手等它 --release-exec(⛔ 不得删锁,R9)" >&2
exit 1
fi
fi
# ★ 临界区:查冲突 + 建锁必须原子 ⇒ 用 .gate 串行化(否则两会话可能同时判"无冲突")
# ⚠️ 实测坑(2026-09-22):临界区内被 SIGTERM/SIGPIPE 打断(如管道 `| head` 提前关闭)
# ⇒ `.gate` 泄漏 ⇒ 后续所有抢锁卡满 10 s 才报错。
# ⛔ 清理已由开头注册的 `_cleanup_guard` 统一负责(trap 单槽位,此处**不得**再 trap 覆盖)。
_tries=0
while ! mkdir "$GATELOCK" 2>/dev/null; do
_tries=$((_tries+1))
if [ "$_tries" -gt 100 ]; then
echo "✗ 抢域锁失败:临界区 .gate 被长时间占用(疑似有会话崩在临界区内)" >&2
echo " → 报告用户;⛔ 不得删 .gate(R9)" >&2
# ⛔ 此处 `.gate` 是**别人的**:`_GATE_MINE` 仍为 0 ⇒ 守卫不会删它,可直接退。
exit 1
fi
sleep 0.1
done
_GATE_MINE=1 # ★ 本调用成功创建了临界区 ⇒ 授权守卫在退出时清理它
CONFLICT=""
for d in "${DOMAINS[@]}"; do
hit="$(others_domains | awk -F'\t' -v want="$d" '$1==want {print $2; exit}')"
[ -n "$hit" ] && CONFLICT="${CONFLICT} · 域「$d」已被「$hit」占用"$'\n'
done
if [ -n "$CONFLICT" ]; then
rmdir "$GATELOCK" 2>/dev/null
echo "✗ 抢域锁失败:**冲突域被占用** ——
$CONFLICT → **停手**:等对方释放,或**改做不重叠的域**。⛔ 不得删锁/接管(R9)" >&2
exit 1
fi
# 锁目录名 = 会话名的**可逆安全映射**(一会话一把锁,域清单写在 DOMAINS 里)
# ⚠️ 同会话二次抢不同域 ⇒ **必须合并**,⛔ 不能 rm -rf 重建
# (重建会把第一把域的声明冲掉 ⇒ 别人就能抢进来 = 假绿;2026-09-22 实测踩过)
# 🔴 2026-09-22 实测修复:旧写法 `tr -c 'A-Za-z0-9._-' '_'` 把**所有非 ASCII 字符压成 `_`**
# ⇒ 会话「域锁-A」「域锁-B」「域锁-C」全部映射成同名目录 ⇒ 互相误判成"自己"⇒ **假绿**。
# 本平台会话名**全是中文** ⇒ 这是必现缺陷,不是边界情况。
# 改法:ASCII 安全的直接留用(可读性 + 兼容旧锁目录),否则附加 `cksum` 摘要(唯一、确定、无需外部依赖)。
_safe="$(printf '%s' "$OWNER" | tr -c 'A-Za-z0-9._-' '_')"
case "$OWNER" in
*[!A-Za-z0-9._-]*)
_safe="${_safe:0:40}-$(printf '%s' "$OWNER" | cksum | cut -d' ' -f1)"
;;
esac
MYLOCK="$LOCKSROOT/$_safe"
if [ -d "$MYLOCK" ]; then
_merged="$MYLOCK/.DOMAINS.new"
cat "$MYLOCK/DOMAINS" 2>/dev/null > "$_merged" || true
for d in "${DOMAINS[@]}"; do
grep -qxF "$d" "$_merged" 2>/dev/null || printf '%s\n' "$d" >> "$_merged"
done
sort -u "$_merged" -o "$_merged" 2>/dev/null || true
mv "$_merged" "$MYLOCK/DOMAINS" 2>/dev/null
rmdir "$GATELOCK" 2>/dev/null
echo "✓ 已持域锁($OWNER)—— 合并到已有锁"
echo " 我的域($(wc -l < "$MYLOCK/DOMAINS" | tr -d ' ') 个):"
while IFS= read -r d; do [ -n "$d" ] && echo " · $d"; done < "$MYLOCK/DOMAINS"
echo " ⛔ 锁的生命周期 = 任务的生命周期:完成 → 必须 \`--release-exec\`。中途要停 ⇒ 先释放再停。"
exit 0
fi
if mkdir "$MYLOCK" 2>/dev/null; then
printf '%s\n' "$OWNER" > "$MYLOCK/OWNER"
printf "开始:%s\n会话:%s\nME:%s\n" "$(date "+%m-%d %H:%M")" "${CODEBUDDY_SESSION_ID:-未取到}" "$OWNER" >> "$MYLOCK/OWNER"
printf '%s\n' "${DOMAINS[@]}" > "$MYLOCK/DOMAINS"
rmdir "$GATELOCK" 2>/dev/null
echo "✓ 已持域锁($OWNER)"
echo " 我的域(${#DOMAINS[@]} 个):"
for d in "${DOMAINS[@]}"; do echo " · $d"; done
echo " ⛔ 锁的生命周期 = 任务的生命周期:完成 → 必须 \`--release-exec\`。中途要停 ⇒ 先释放再停。"
exit 0
fi
rmdir "$GATELOCK" 2>/dev/null
echo "✗ 抢域锁失败:建锁失败($MYLOCK)" >&2
exit 1
fi
if [ "${1:-}" = "--release-exec" ]; then
# 🔴 2026-09-25 修复(原缺陷:未传 ME ⇒ 释放**所有人**的锁)
# 旧写法 `[ -z "$_who" ] || [ "$o" = "$_who" ]`:ME 未设时 _who="" ⇒ 前半**恒真**
# ⇒ 遍历 .locks/* 把**所有会话**的域锁 rm -rf(不只自己的)。
# 2026-09-25 19:5x 实测两回:① 抹掉自己的锁(表现为"锁记录莫名消失");
# ② 抹掉别会话域锁 `p33-exec-plugin-dataline` ⇒ 丢锁方在**无锁状态下继续写**,
# 而域锁的全部意义就是防这种并发写。
# 另一处不对称:`--claim-exec <会话名>` 用 **$2** 传名,而 release 只认 `ME` ⇒
# 照 claim 的姿势写 `--release-exec "<会话名>"` 会被**静默忽略**(名进了 $2、没人读)。
# 现修法:① 会话名取 `ME` 或 `$2`(与 claim 对称)② 都取不到 ⇒ **fail-closed 拒绝释放**
# (⛔ 不再"什么都不动"地静默成功,也不再默认清空 —— 与 R9「锁只能由持有者释放」一致)。
_who="${ME:-${2:-}}"
if [ -z "$_who" ]; then
echo "✗ 拒绝释放:未指定会话名 —— 无法判定锁的归属(⛔ 一个锁都没动)。" >&2
echo " 正确用法(二选一):" >&2
echo " ME=\"<会话名>\" bash 07-scripts/handoff-guard.sh --release-exec" >&2
echo " bash 07-scripts/handoff-guard.sh --release-exec \"<会话名>\" # 与 --claim-exec 对称" >&2
echo " 理由:按 R9 锁只能由持有者释放;取不到名字时**一律不动任何锁**。" >&2
exit 1
fi
_rel=0
_foreign=0
if [ -d "$LOCKSROOT" ]; then
for L in "$LOCKSROOT"/*; do
[ -d "$L" ] || continue
[ "$(basename "$L")" = ".gate" ] && continue
[ "$(basename "$L")" = ".migrations" ] && continue
o="$(sed -n '1p' "$L/OWNER" 2>/dev/null)"
if [ "$o" = "$_who" ]; then
rm -rf "$L" && echo "✓ 已释放域锁($o)" && _rel=1
else
_foreign=$((_foreign + 1))
fi
done
fi
if [ -d "$LOCKEXEC" ]; then
o1="$(sed -n '1p' "$LOCKEXEC/OWNER" 2>/dev/null)"
if [ "$o1" = "$_who" ]; then
rm -rf "$LOCKEXEC" && echo "✓ 已释放全局执行锁" && _rel=1
else
_foreign=$((_foreign + 1))
fi
fi
[ "$_rel" = 1 ] || echo "· 「$_who」名下无可释放的锁"
[ "$_foreign" -gt 0 ] && echo "· 另有 $_foreign 把锁属**他人**,按 R9 未动(要处置只能由持有者本人释放)"
exit 0
fi
# ── 骨架锁:层④/迁移号/唯一挂载点 ⇒ 秒级独占 ──────────────
if [ "${1:-}" = "--claim-skeleton" ]; then
RES="${2:-}"; OWNER="${3:-${ME:-$(whoami)}}"
[ -n "$RES" ] || { echo "用法:--claim-skeleton <资源名> [占用者]" >&2; exit 2; }
mkdir -p "$LOCKSROOT/.migrations" 2>/dev/null
SK="$LOCKSROOT/.migrations/$RES"
if mkdir "$SK" 2>/dev/null; then
printf '%s\n%s\n' "$OWNER" "$(date '+%m-%d %H:%M')" > "$SK/OWNER"
echo "✓ 已领骨架资源:$RES($OWNER)—— 完后请 --release-skeleton $RES"
exit 0
fi
echo "✗ 领骨架资源失败:$RES 已被占($(sed -n '1p' "$SK/OWNER" 2>/dev/null || echo '?'))→ 停手或换名(如迁移号 +1)" >&2
exit 1
fi
if [ "${1:-}" = "--release-skeleton" ]; then
RES="${2:-}"; [ -n "$RES" ] || { echo "用法:--release-skeleton <资源名>" >&2; exit 2; }
# 🔴 2026-09-25 加归属校验(同 --release-exec 的同类缺陷):原先按名字直接 rm -rf,
# 会话只要知道资源名就能删别人的骨架锁 ⇒ release 成了绕过 R9 的后门。
# 例外:OWNER == 本机 whoami ⇒ 视为"claim 时没记会话名"的锁,允许清理(否则永远卡住)。
_who="${ME:-${3:-}}"
_own="$(sed -n '1p' "$LOCKSROOT/.migrations/$RES/OWNER" 2>/dev/null)"
if [ -z "$_who" ]; then
echo "✗ 拒绝释放骨架资源「$RES」:未指定会话名(⛔ 没动它)。" >&2
echo " 用法:ME=\"<会话名>\" bash 07-scripts/handoff-guard.sh --release-skeleton $RES" >&2
exit 1
fi
if [ "$_own" = "$_who" ] || { [ -n "$_own" ] && [ "$_own" = "$(whoami)" ]; }; then
rm -rf "$LOCKSROOT/.migrations/$RES" && echo "✓ 已释放骨架资源:$RES(归属已核:$_own)"
else
echo "✗ 拒绝释放骨架资源「$RES」:占用者是「${_own:-?}」,而你是「$_who」(⛔ 没动它,R9)。" >&2
exit 1
fi
exit 0
fi
# ── 发布锁:commit / push / scp / build ⇒ 秒级独占(仓库级,不可并行)────
if [ "${1:-}" = "--claim-publish" ]; then
OWNER="${2:-${ME:-$(whoami)}}"
mkdir -p "$LOCKSROOT" 2>/dev/null
PB="$LOCKSROOT/.publish"
if mkdir "$PB" 2>/dev/null; then
printf '%s\n%s\n' "$OWNER" "$(date '+%m-%d %H:%M')" > "$PB/OWNER"
echo "✓ 已持发布锁($OWNER)—— commit/push/scp/build 期间不可并行;完后立刻 --release-publish"
exit 0
fi
echo "✗ 发布锁被占($(sed -n '1p' "$PB/OWNER" 2>/dev/null || echo '?'))→ 停手等它释放" >&2
exit 1
fi
if [ "${1:-}" = "--release-publish" ]; then
# 🔴 2026-09-25 加归属校验(同 --release-exec 的同类缺陷)。例外同上:OWNER == whoami 可清。
_who="${ME:-${2:-}}"
_own="$(sed -n '1p' "$LOCKSROOT/.publish/OWNER" 2>/dev/null)"
if [ -z "$_who" ]; then
echo "✗ 拒绝释放发布锁:未指定会话名(⛔ 没动它)。" >&2
echo " 用法:ME=\"<会话名>\" bash 07-scripts/handoff-guard.sh --release-publish" >&2
exit 1
fi
if [ "$_own" = "$_who" ] || { [ -n "$_own" ] && [ "$_own" = "$(whoami)" ]; }; then
rm -rf "$LOCKSROOT/.publish" && echo "✓ 已释放发布锁(归属已核:$_own)"
else
echo "✗ 拒绝释放发布锁:占用者是「${_own:-?}」,而你是「$_who」(⛔ 没动它,R9)。" >&2
exit 1
fi
exit 0
fi
# ── 锁总览(域锁 + 骨架锁 + 发布锁 + 旧全局锁)────────────
if [ "${1:-}" = "--locks" ]; then
echo "—— 锁总览 ——"
[ -d "$LOCKEXEC" ] && { echo " [全局锁] $(sed -n '1p' "$LOCKEXEC/OWNER" 2>/dev/null)(未升级会话,全局独占)"; } || echo " [全局锁] 空闲"
if [ -d "$LOCKSROOT" ]; then
for L in "$LOCKSROOT"/*; do
[ -d "$L" ] || continue
b="$(basename "$L")"
case "$b" in .gate) continue;; .migrations)
for M in "$LOCKSROOT/.migrations"/*; do
[ -d "$M" ] || continue
echo " [骨架] $(basename "$M") ← $(sed -n '1p' "$M/OWNER" 2>/dev/null)"
done; continue;; .publish)
echo " [发布] $(sed -n '1p' "$L/OWNER" 2>/dev/null) ← 期间不可并行 commit/push/scp"; continue;; esac
echo " [域锁] $(sed -n '1p' "$L/OWNER" 2>/dev/null)|域:$(tr '\n' ' ' < "$L/DOMAINS" 2>/dev/null)"
done
fi
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 "✓ 已占位:05-交接单/.doing-$T($OWNER)—— 完工请 --release $T"
exit 0
fi
echo "✗ 占位失败:05-交接单/.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 "✓ 已释放:05-交接单/.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】占用锁(05-交接单/.doing-*)"
shopt -s nullglob
LOCKS=("$LOCKDIR"*)
if [ ${#LOCKS[@]} -eq 0 ]; then
echo " ✓ 无人占用 —— ⚠ 这不是「可以开工」,是「**你快去抢**」:"
echo " 开工前先:ME=\"<你的会话名>\" bash 07-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-" 05-交接单/README.md 2>/dev/null || true)"
if [ -z "$row" ]; then
echo " ⚠ $T 有占用锁,但 \`05-交接单/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】全局执行锁(05-交接单/.exec-lock)"
if [ ! -d "$LOCKEXEC" ]; then
echo " ✓ 无全局锁 —— ⚠️ 这**不是「可以开工」,是「你快去抢」**:"
echo " 开工前必须先:bash 07-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 07-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 '^05-交接单/\.(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 './05-交接单/.doing-*' -not -path './05-交接单/.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 07-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 07-scripts/docs-sync-check.sh 2>/dev/null)"
else
SYNC="$(bash 07-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 " (未找到 07-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"