build / build-and-scan (push) Waiting to run
文档库:目录改为编号制(01-规范/02-架构设计/03-数据库/04-调整方案/
05-交接单/06-ops/07-scripts/08-skills/09-archive),顶层散文件归入 01-规范/;
INDEX.md 与 docs-manifest.json 重刷(档案 146 篇);旧目录名引用全量对齐。
IM 线:src/im/**(SDK / hub / store / presence / ws / gateway-token)、
src/web/routes/im.ts、src/db/plugin-data/**、src/supervisor/plugin-assembly.ts
及对应 test/**。
插件线:poc/{im-agent-bridge,im-connection-gateway,im-conversation-tabs,
business-plugins-im,carbon-mcp-probe}、src/web/routes/{sessions,overlay-device}.ts、
src/net/relay/{device-grant,instance-credential}.ts。
仓库卫生:清出 40 个历史误入库 / 已改名文件(34 个交接单归档 + 6 个旧结构,
本地均有副本);dsh-server-docs/.gitignore 补 tmp/;交接单不入库(政策)。
563 lines
30 KiB
Bash
563 lines
30 KiB
Bash
#!/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 # 再放全局
|
||
# 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
|
||
|
||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && 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 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
|
||
_who="${ME:-}"
|
||
_rel=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 [ -z "$_who" ] || [ "$o" = "$_who" ]; then
|
||
rm -rf "$L" && echo "✓ 已释放域锁($o)" && _rel=1
|
||
fi
|
||
done
|
||
fi
|
||
if [ -d "$LOCKEXEC" ]; then
|
||
o1="$(sed -n '1p' "$LOCKEXEC/OWNER" 2>/dev/null)"
|
||
if [ -z "$_who" ] || [ "$o1" = "$_who" ]; then
|
||
rm -rf "$LOCKEXEC" && echo "✓ 已释放全局执行锁" && _rel=1
|
||
fi
|
||
fi
|
||
[ "$_rel" = 1 ] || echo "· 无可释放的锁(或非本人持有)"
|
||
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; }
|
||
rm -rf "$LOCKSROOT/.migrations/$RES" && echo "✓ 已释放骨架资源:$RES"
|
||
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
|
||
rm -rf "$LOCKSROOT/.publish" && echo "✓ 已释放发布锁"
|
||
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"
|