From 037a11c3a0411a2a18aea9209c471e8110ef939a Mon Sep 17 00:00:00 2001 From: Seller-1990 Date: Thu, 8 Oct 2026 10:17:40 +0800 Subject: [PATCH 1/3] =?UTF-8?q?chore(ocr):=20=E8=AF=84=E5=AE=A1=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E7=A7=BB=E6=A4=8D=20Windows/Git=20Bash=20=E9=80=82?= =?UTF-8?q?=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留成熟版全部机制(锁所有权、原子发布、JSON 状态白名单、ref 校验),仅改平台差异: - 公共 git 目录定位(git rev-parse --git-common-dir):worktree 下 .git 是文件, 写死 .git 会让锁与报告路径失效(指南 §11 第 11 条) - GNU timeout 优先:Git Bash/coreutils 环境语义更清晰(超时码 124 并带走子进程树); 无 timeout 时仍走原进程组方案(macOS 兼容不变) - python 优先于 python3:Windows 的 python3 常是 WindowsApps 应用执行别名 - PATH 补 Windows npm 全局目录(%APPDATA%/npm) - 超时判定加入退出码 124 --- scripts/ocr-review.sh | 56 +++++++++++++++++++++++++++++++------------ 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/scripts/ocr-review.sh b/scripts/ocr-review.sh index 3c5b0e6..effdaa7 100755 --- a/scripts/ocr-review.sh +++ b/scripts/ocr-review.sh @@ -1,5 +1,9 @@ #!/bin/bash -# ocr 评审包装脚本(zcode-advisor) +# ocr 评审包装脚本(标准链版 · 新机 Windows 适配) +# 基线:zcode-advisor origin/main 的成熟版(锁所有权/原子发布/JSON 状态白名单全保留), +# 仅移植平台差异:① 公共 git 目录定位(worktree 安全)② GNU timeout 优先 +# ③ python 优先于 WindowsApps 的 python3 壳 ④ Windows npm 全局路径补 PATH +# ⑤ timeout 退出码 124 计入「被强制终止」判定。 # # 设计(与 ~/.dsh/AGENTS.md 一致): # - 评审工具**不进 CI**:模型网关是内网地址(192.168.50.139),GitHub runner 够不着; @@ -28,6 +32,13 @@ set -uo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$REPO_ROOT" || exit 1 +# 公共 git 目录:worktree 下 .git 是文件不是目录,写死会失败(指南 §11 第 11 条)。 +GIT_COMMON_DIR="$(git rev-parse --git-common-dir 2>/dev/null || echo .git)" +case "$GIT_COMMON_DIR" in + /*|?:*) ;; + *) GIT_COMMON_DIR="$REPO_ROOT/$GIT_COMMON_DIR" ;; +esac + # ── 并发锁(实测教训)── # pre-push hook 每次推送都会起一个评审,而完整评审要 5–15 分钟。 # 连续推送(或 tag + main 分开推)会让多个评审同时跑:既争抢上游配额, @@ -38,7 +49,7 @@ cd "$REPO_ROOT" || exit 1 # 锁内含 pid + 起始时间;发现锁时: # - 持锁进程仍活着 → 提示并退出(不重复评审) # - 持锁进程已死(崩溃/被 kill)→ 视为陈旧锁,接管 -LOCK_DIR="$REPO_ROOT/.git/ocr-review.lock" +LOCK_DIR="$GIT_COMMON_DIR/ocr-review.lock" LOCK_INFO="$LOCK_DIR/info" # 只有**自己拿到锁**才允许在退出时删锁。OCR_REVIEW_FORCE=1 整段跳过、根本不取锁, # 若无条件删就会把另一个正在跑的评审的锁连根拔掉——而并行评审正是 FORCE 的用途, @@ -55,7 +66,7 @@ if [ -z "${OCR_REVIEW_FORCE:-}" ]; then [ -f "$LOCK_INFO" ] && HOLD_PID="$(sed -n 's/^pid=//p' "$LOCK_INFO" 2>/dev/null)" if [ -n "$HOLD_PID" ] && kill -0 "$HOLD_PID" 2>/dev/null; then echo "[ocr-review] 已有评审在进行(pid ${HOLD_PID}),本次跳过以免重复消耗。" - echo "[ocr-review] 已有报告:$REPO_ROOT/.git/ocr-review-last.txt" + echo "[ocr-review] 已有报告:$GIT_COMMON_DIR/ocr-review-last.txt" echo "[ocr-review] 如需强制并行,设 OCR_REVIEW_FORCE=1" exit 0 fi @@ -81,7 +92,7 @@ if ! command -v ocr >/dev/null 2>&1; then fi # 再按版本号倒序扫(字典序会把 v10 排在 v20 之前,可能命中陈旧的全局安装) if ! command -v ocr >/dev/null 2>&1; then - for candidate in $(ls -1d "$HOME"/.nvm/versions/node/*/bin 2>/dev/null | sort -Vr) /usr/local/bin /opt/homebrew/bin; do + for candidate in $(ls -1d "$HOME"/.nvm/versions/node/*/bin 2>/dev/null | sort -Vr) \n /usr/local/bin /opt/homebrew/bin \n "${APPDATA:-$HOME/AppData/Roaming}/npm" "$HOME/AppData/Roaming/npm"; do if [ -x "$candidate/ocr" ]; then PATH="$candidate:$PATH" break @@ -97,6 +108,11 @@ fi AGENT_MODEL_FILE="$HOME/.opencodereview/agent-model" +# python 解释器:Windows 上 python3 常是 WindowsApps 应用执行别名(会打 shebang 警告), +# 优先用真实安装的 python;两者都没有时 python3 兜底(macOS 无 python 只有 python3)。 +PY="$(command -v python || command -v python3 || true)" +[ -n "$PY" ] || { echo "错误:找不到 python 解释器(渲染评审结果需要)" >&2; exit 1; } + # 校验 ref 是否真实存在(避免把非法参数带进 ocr) require_ref() { local ref="$1" @@ -144,17 +160,20 @@ AGENT_MODEL="$(tr -d '[:space:]' < "$AGENT_MODEL_FILE" 2>/dev/null || true)" CHAIN_FILE="${OCR_REVIEW_CHAIN_FILE:-$HOME/.dsh/templates/ocr-review/chain-public}" CHAIN_SCOPE="public" # public 副本:cloud 条目合法,但仍以链文件为唯一真源 CHAIN=( - "nas-hy4|deepseek-v4.1-flash" - "runanytime|z-ai/glm-5.2" - "nas-octopus|glm-5.3-flash" - "nas-hy4|hy3" - "nas-hy4|hy4-preview-f" + "nas-hy4|space-bunny" "x666|ministral-14b-latest" "runanytime-astra|gpt-6-astra" - "runanytime|z-ai/glm-5.3" - "lucky|stealth/space-bunny-alpha" "lucky|step-5-preview" - "daigua|gpt-6-sol" + "nas-octopus|gemini-3.7-flash" + "nas-hy4|gemini-3.5-flash" + "nas-hy4|deepseek-v4.1-flash" + "nas-hy4|deepseek-v4.1-flash-sg" + "nas-hy4|glm-5.3" + "nas-hy4|gpt-6-astra" + "nas-hy4|gemini-3.8-flash" + "nas-hy4|kimi-k2.8-preview" + "nas-hy4|hy3" + "nas-hy4|hy4-preview-f" ) # 运行时优先读链文件:读不到 / 解析出 0 条 → 保留上面的快照(脚本被单独拷走也跑得起来)。 _chain_found=() @@ -289,7 +308,7 @@ trap cleanup EXIT INT TERM # 误拒完整结果、触发无谓重跑。覆盖完整性以 status 为准。 valid_output() { [ -s "$OUT_JSON" ] || return 1 - python3 - "$OUT_JSON" <<'PYCHECK' >/dev/null 2>&1 + "$PY" - "$OUT_JSON" <<'PYCHECK' >/dev/null 2>&1 import json, sys try: with open(sys.argv[1], encoding="utf-8") as fh: @@ -323,6 +342,13 @@ run_with_deadline() { local secs="$1"; shift case "$secs" in ''|*[!0-9]*) "$@"; return $? ;; esac [ "$secs" -gt 0 ] || { "$@"; return $?; } + # 新机(Windows Git Bash / 装了 coreutils 的机器)有 GNU timeout,优先用它: + # 语义清晰(超时退出码 124),且 MSYS 会一并带走子进程树。 + # 没有 timeout 时才走下面的进程组方案(macOS 自带 bash 3.2 的原始实现)。 + if command -v timeout >/dev/null 2>&1; then + timeout "$secs" "$@" + return $? + fi set -m "$@" & local cmd_pid=$! @@ -391,7 +417,7 @@ for entry in "${CHAIN[@]}"; do break fi case "$HOP_RC" in - 137|143) echo " ⚠ 本跳到 ${HOP_LIMIT}s 上限被强制终止(退出码 ${HOP_RC}),降级到下一个" ;; + 124|137|143) echo " ⚠ 本跳到 ${HOP_LIMIT}s 上限被强制终止(退出码 ${HOP_RC}),降级到下一个" ;; esac # 保留该次失败输出尾部,供全失败时排查(此前一律丢进 /dev/null) if [ -n "$ATTEMPT_LOG" ]; then @@ -426,7 +452,7 @@ fi # { status, llm, message, summary, tool_calls, comments[], groups[], session_id, manifest, retry_report } # comments[] 元素键:path, content, suggestion_code, existing_code, start_line, end_line, thinking, category, severity # 渲染失败必须让脚本非零退出(set -e 未开启,故显式传递)。 -if ! python3 - "$OUT_JSON" "$CHOSEN" <<'PY' +if ! "$PY" - "$OUT_JSON" "$CHOSEN" <<'PY' import json, sys path, chosen = sys.argv[1], sys.argv[2] From 149723689997a8bec17ee2c0755bd01a0697369a Mon Sep 17 00:00:00 2001 From: Seller-1990 Date: Thu, 8 Oct 2026 10:43:07 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(ocr):=20GNU=20timeout=20=E6=8E=A2?= =?UTF-8?q?=E6=B5=8B=E6=94=B9=E7=94=A8=20--version=EF=BC=88=E9=81=BF?= =?UTF-8?q?=E5=BC=80=20Windows=20=E5=90=8C=E5=90=8D=20timeout.exe=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 评审(PR #14)指出 Windows 兼容性风险,逐条核实后确认为真问题: Windows 自带 C:/Windows/System32/timeout.exe 是同名异物(等 N 秒的延时命令, 不包装别的命令)。原判定用 `command -v timeout`,若 PATH 解析命中该 exe, `timeout 900 ocr review …` 会被它当成非法参数静默跑偏(实测:报「指定的超时(/T)值无效」 但退出码为 0,脚本无从察觉)。 改为 `timeout --version` 探测:GNU 版有该选项,Windows 版没有(实测两者行为可区分)。 其余 8 条 CI 意见经核实为误报: - 路径拼接/cygpath(3 条):Git Bash 下 MSYS 自动转换 / 与 \,实测锁与报告路径均正常 - kill -0 不可靠:实测对存活/已死进程判定均正确 - PY 变量注入/校验:command -v 无注入面;python 3.12 实测可用 - 超时码 124/137/143 不一致:已按平台分别覆盖,属设计意图 --- scripts/ocr-review.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/ocr-review.sh b/scripts/ocr-review.sh index effdaa7..415e0c1 100755 --- a/scripts/ocr-review.sh +++ b/scripts/ocr-review.sh @@ -344,8 +344,12 @@ run_with_deadline() { [ "$secs" -gt 0 ] || { "$@"; return $?; } # 新机(Windows Git Bash / 装了 coreutils 的机器)有 GNU timeout,优先用它: # 语义清晰(超时退出码 124),且 MSYS 会一并带走子进程树。 - # 没有 timeout 时才走下面的进程组方案(macOS 自带 bash 3.2 的原始实现)。 - if command -v timeout >/dev/null 2>&1; then + # + # ⚠️ 必须用 `timeout --version` 探测而不是 `command -v timeout`:Windows 自带 + # C:/Windows/System32/timeout.exe 是同名异物(等 N 秒的延时命令,不包装别的命令), + # 命中它会把 `timeout 900 ocr review …` 当成非法参数静默跑偏。 + # GNU 版有 --version,Windows 版没有(实测其退出码与输出均不同)。 + if timeout --version >/dev/null 2>&1; then timeout "$secs" "$@" return $? fi From 2b548b2500d0ee7b121bbb47f777a24225988ef1 Mon Sep 17 00:00:00 2001 From: Seller-1990 Date: Thu, 8 Oct 2026 16:18:38 +0800 Subject: [PATCH 3/3] =?UTF-8?q?chore(ocr):=20=E5=B9=B3=E5=8F=B0=E9=80=82?= =?UTF-8?q?=E9=85=8D=E4=BB=A5=20main=20=E4=B8=BA=E5=9F=BA=E7=BA=BF?= =?UTF-8?q?=E9=87=8D=E5=81=9A=EF=BC=8C=E5=8E=BB=E6=8E=89=E6=89=8B=E6=94=B9?= =?UTF-8?q?=E7=9A=84=E9=93=BE=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原两次提交把适配写成对 main 的分叉副本,带来三个问题: - CHAIN 快照被手工重排为 14 条,与唯一真源 chain-public(16 条)不一致, 且含链文件标注已 failed / 必然超时的条目; - 缺 main 已有的 mktemp 防符号链接写穿修复、record_use 的 skip 判定; - 第 95 行用了字面 \n 而非换行续行。 本提交把该文件重建为「origin/main 原文 + 平台适配」,并同步进模板 (~/.dsh/templates/ocr-review/ocr-review.sh 与 .private,模板才是 standard 模式的复制源,改副本不落模板会在下次同步时被整文件覆盖): - 公共 git 目录定位(worktree 下 .git 是文件),锁与报告路径改走 GIT_COMMON_DIR; - PATH 候选补 Windows npm 全局目录,续行改为真换行; - Python 解释器探测带版本校验(拒绝 Python 2,内嵌脚本用 f-string); - GNU timeout 优先(用 --version 探测,避开 Windows 同名 timeout.exe); - 超时退出码 124 计入强制终止判定; - 链快照回到与 chain-public 一致的 16 条。 --- scripts/ocr-review.sh | 84 +++++++++++++++++++++++-------------------- 1 file changed, 45 insertions(+), 39 deletions(-) diff --git a/scripts/ocr-review.sh b/scripts/ocr-review.sh index 415e0c1..0424793 100755 --- a/scripts/ocr-review.sh +++ b/scripts/ocr-review.sh @@ -1,27 +1,27 @@ #!/bin/bash -# ocr 评审包装脚本(标准链版 · 新机 Windows 适配) -# 基线:zcode-advisor origin/main 的成熟版(锁所有权/原子发布/JSON 状态白名单全保留), -# 仅移植平台差异:① 公共 git 目录定位(worktree 安全)② GNU timeout 优先 -# ③ python 优先于 WindowsApps 的 python3 壳 ④ Windows npm 全局路径补 PATH -# ⑤ timeout 退出码 124 计入「被强制终止」判定。 +# ocr 评审包装脚本(zcode-advisor) +# +# 平台适配(Windows / Git Bash 与新机,2026-10-08): +# ① 公共 git 目录定位(worktree 下 .git 是文件,写死会 ENOTDIR)② GNU timeout 优先 +# ③ python 优先于 WindowsApps 的 python3 壳 ④ Windows npm 全局路径补 PATH +# ⑤ timeout 退出码 124 计入「被强制终止」判定。 +# 这几项是**脚本级能力**:必须落在模板里,否则下次跑 ocr-sync-reviewer.sh 时 +# 会被整文件覆盖掉(standard 模式 = 模板内容直写)。 # # 设计(与 ~/.dsh/AGENTS.md 一致): # - 评审工具**不进 CI**:模型网关是内网地址(192.168.50.139),GitHub runner 够不着; # 拦截靠 CI 的确定性检查,评审只作为 advisory 关卡。 # - 也**不定时跑**:只在每个 PR 合并前调用一次。 # -# 模型降级链(provider|model,与 ~/.opencodereview/config.json 的 custom_providers 对应): -# ⚠️ 链的**唯一真源**是 ~/.dsh/templates/ocr-review/chain-public(运行时直接读它)。 -# 本文件里那段 CHAIN 数组只是读不到链文件时的**兜底快照**,别在这里找当前顺序—— -# 它必然滞后(改链不必改脚本,也就没人会记得同步它)。 -# 改链:编辑 chain-public 再跑 bash ~/.dsh/bin/ocr-sync-reviewer.sh 刷快照。 -# 这里不再逐条列举模型名与顺序:链外置的全部意义就是「枚举会过期」,再抄一份 -# 只会制造第二处会撒谎的注释(2026-10-06 实测:注释写着主棒 deepseek,运行时首跳 -# 其实是 chain-public 的 minimax-m3)。各模型的可用性/耗时/成本结论写在链文件里, -# 那才是它们生效的地方。 -# 【2026-10-04】隐私铁律暂缓(用户拍板):私有仓也可用本链(私有副本用 ocr-review.sh.private -# 变体,链走 chain-private,同样允许 cloud 条目)。 -# ⚠️ kimi-k3 不支持工具调用(三次探针无 tool_calls)→ 不能当 ocr 模型,勿加回。 +# 模型降级链:**唯一真源是 ~/.dsh/templates/ocr-review/chain-public**(本脚本下方的 +# CHAIN=(...) 只是读不到链文件时的兜底快照,由 ocr-sync-reviewer.sh 生成,勿手改)。 +# 此处不再逐条列举模型名与顺序——枚举必然过期(2026-10-06 实测:本注释曾写"主棒 +# deepseek",而运行时首跳早已是链文件里的另一个模型)。要改链请改链文件。 +# 【2026-10-04】隐私铁律暂缓(用户拍板):私有仓也可用云端条目(私有副本用 +# ocr-review.sh.private 变体,链走 chain-private,同样允许 cloud 条目)。 +# ⚠️ 同站 kimi-k3 不支持工具调用(2026-10-04 三次探针无 tool_calls)→ 不能当 ocr 模型,勿加回。 +# ⚠️ lucky / lucky-gem 需在 config.json 配 user_agent + headers.User-Agent,否则被 +# Cloudflare 按 UA 拦截返回 403(不是服务失效)。 # 用法: # ./scripts/ocr-review.sh # 默认基准 origin/dev(不存在则回退 main) # ./scripts/ocr-review.sh v1.8.8 # 对比 tag / 分支 @@ -32,10 +32,10 @@ set -uo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$REPO_ROOT" || exit 1 -# 公共 git 目录:worktree 下 .git 是文件不是目录,写死会失败(指南 §11 第 11 条)。 +# 公共 git 目录:worktree 下 .git 是文件不是目录,写死会失败(AGENTS.md §11 第 11 条)。 GIT_COMMON_DIR="$(git rev-parse --git-common-dir 2>/dev/null || echo .git)" case "$GIT_COMMON_DIR" in - /*|?:*) ;; + /*|?:*) ;; # 已是绝对路径(worktree 常见) *) GIT_COMMON_DIR="$REPO_ROOT/$GIT_COMMON_DIR" ;; esac @@ -92,7 +92,10 @@ if ! command -v ocr >/dev/null 2>&1; then fi # 再按版本号倒序扫(字典序会把 v10 排在 v20 之前,可能命中陈旧的全局安装) if ! command -v ocr >/dev/null 2>&1; then - for candidate in $(ls -1d "$HOME"/.nvm/versions/node/*/bin 2>/dev/null | sort -Vr) \n /usr/local/bin /opt/homebrew/bin \n "${APPDATA:-$HOME/AppData/Roaming}/npm" "$HOME/AppData/Roaming/npm"; do + for candidate in \ + $(ls -1d "$HOME"/.nvm/versions/node/*/bin 2>/dev/null | sort -Vr) \ + /usr/local/bin /opt/homebrew/bin \ + "${APPDATA:-$HOME/AppData/Roaming}/npm" "$HOME/AppData/Roaming/npm"; do if [ -x "$candidate/ocr" ]; then PATH="$candidate:$PATH" break @@ -108,10 +111,18 @@ fi AGENT_MODEL_FILE="$HOME/.opencodereview/agent-model" -# python 解释器:Windows 上 python3 常是 WindowsApps 应用执行别名(会打 shebang 警告), -# 优先用真实安装的 python;两者都没有时 python3 兜底(macOS 无 python 只有 python3)。 -PY="$(command -v python || command -v python3 || true)" -[ -n "$PY" ] || { echo "错误:找不到 python 解释器(渲染评审结果需要)" >&2; exit 1; } +# python 解释器:Windows 上 python3 常是 WindowsApps 应用执行别名(跑起来只会弹应用商店), +# 故优先真实安装的 python;但内嵌脚本用了 f-string,**只接受 Python 3** —— 若 python 指向 +# Python 2 就跳过它继续找 python3,不能靠"命令存在"就认定可用。 +PY="" +for _cand in python python3; do + _p="$(command -v "$_cand" 2>/dev/null || true)" + [ -n "$_p" ] || continue + if "$_p" -c 'import sys; sys.exit(0 if sys.version_info[0] == 3 else 1)' 2>/dev/null; then + PY="$_p"; break + fi +done +[ -n "$PY" ] || { echo "错误:找不到可用的 Python 3 解释器(渲染评审结果需要)" >&2; exit 1; } # 校验 ref 是否真实存在(避免把非法参数带进 ocr) require_ref() { @@ -161,17 +172,19 @@ CHAIN_FILE="${OCR_REVIEW_CHAIN_FILE:-$HOME/.dsh/templates/ocr-review/chain-publi CHAIN_SCOPE="public" # public 副本:cloud 条目合法,但仍以链文件为唯一真源 CHAIN=( "nas-hy4|space-bunny" + "lucky-gem|gemini-3.6-flash" "x666|ministral-14b-latest" "runanytime-astra|gpt-6-astra" "lucky|step-5-preview" + "daigua|gpt-6-sol" + "nas-hy4|gemini-3.8-flash" "nas-octopus|gemini-3.7-flash" - "nas-hy4|gemini-3.5-flash" "nas-hy4|deepseek-v4.1-flash" "nas-hy4|deepseek-v4.1-flash-sg" - "nas-hy4|glm-5.3" - "nas-hy4|gpt-6-astra" - "nas-hy4|gemini-3.8-flash" - "nas-hy4|kimi-k2.8-preview" + "nas-octopus|deepseek-v4-flash-0731" + "runanytime|z-ai/glm-5.2" + "runanytime|z-ai/glm-5.3" + "nas-hy4|glm-5.3-flash" "nas-hy4|hy3" "nas-hy4|hy4-preview-f" ) @@ -234,16 +247,9 @@ OUT_JSON="$(mktemp "${TMPDIR_OCR%/}/ocr-review-XXXXXX" 2>/dev/null)" || { } [ -n "$OUT_JSON" ] || { echo "错误:mktemp 未返回路径" >&2; exit 1; } ATTEMPT_LOG="" -# 注意:这个 trap 会覆盖前面的锁清理 trap,所以 rm -rf "$LOCK_DIR" 必须在这里再做一次, -# 否则锁目录永不消失,之后每次运行都误报"陈旧锁"(虽然能接管,但看着像坏了)。 -# OCR_REVIEW_RAW_OUT(可选,由调用方提供,例如 pre-push hook):把 ocr 的**原始 JSON** -# 另存一份。它是权威产物(下面的渲染表由它生成),而 OUT_JSON 只是 mktemp 临时文件、 -# 退出时被本函数删掉 —— 此前评审成功后原始 JSON 一并消失,事后无法复核或重渲染。 -# -# 两道守卫,缺一不可: -# - `$CHOSEN` 非空 = 确有一跳产出完整评审。**不能**只看 OUT_JSON 非空:链全失败时 -# OUT_JSON 里留着最后一跳的 partial/无效输出,照拷会把上一份好结论覆盖成半成品。 -# - 命中锁而提前退出的进程根本走不到这里(锁检查在 OUT_JSON 创建之前)。 +# 注意:这个 trap 会覆盖前面的锁清理 trap,所以锁回收必须在这里再做一次。 +# 但只在**自己持锁**时回收(LOCK_ACQUIRED)——FORCE=1 根本没取锁,无条件删会把 +# 并行评审的锁拆掉。 cleanup() { if [ -n "${OCR_REVIEW_RAW_OUT:-}" ] && [ -n "${CHOSEN:-}" ]; then # 这里不再检查 OUT_JSON 非空:$CHOSEN 只在 valid_output 通过后赋值,而 valid_output