Files
admin e6207aa691
build / build-and-scan (push) Waiting to run
chore(仓库对齐): 文档库结构治理 + IM/插件线落地
文档库:目录改为编号制(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/;交接单不入库(政策)。
2026-09-24 07:25:16 +08:00

563 lines
30 KiB
Bash
Raw Permalink 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 只作提示、不作判定**(它分不清“谁改的”)。真正的判定来自四处:
# ① 全局执行锁(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"