Skip to content

Commit dea4d08

Browse files
committed
fix(gate): 会话实测二批修复——并发写原子化 + 指纹截断盲区 + UPS 问句句级抑制
基于 zcode 侧 codex-java-sdk 三分支发布全程实战(双副本挂载 + 并行会话共用 工作树场景),codegraph 全量索引后定位的四项不足: 1. dedup/gate-cache 写入原子化:hook_dedup.json 与 gate cache 此前为裸 read-modify-write——UPS 与 PreToolUse 数秒窗口内先后执行会互相覆盖 (丢抑制事件→双份报告)或读到半截 JSON(ValueError 静默吞→去重失效)。 加 fcntl 进程锁(Windows 降级可选)+ tmp+os.replace 原子替换; dedup 时间戳从 monotonic 改墙钟(monotonic 跨重启回绕,不能持久化)。 2. _worktree_fingerprint 去掉 [:500] 截断:截断使第 501+ 文件的修复在 60s 缓存窗口内被旧指纹掩盖(retry-timing 陷阱对大改动面复活)。 名字集合全量入 hash,stat 叠加不再封顶(每次仅对变更文件 stat,成本可控)。 3. UPS 问句抑制改句级:`这个方案 OK 吗?帮我提交` 问句与祈使混排时整条被 静默——改为按句子切分,仅当全部触发句均为问句才静默。 4. languages.json 移除 .zsh 扩展(gate_lib 剥离兜住 run_gate 路径,但 fix.py/run_check 直调仍会送检);docs/LANGUAGES.md 同步重生成。 5. tests/run_all fail-open 测试免疫本机 codeguard.skipGate 配置—— 此前依赖仓库本地 git config 未设 skipGate,环境污染即假失败(实测复现)。 测试:141 通过 / 0 失败(run_all 全量);ruff hooks/+tests/ 全过。
1 parent 571dcd3 commit dea4d08

5 files changed

Lines changed: 58 additions & 28 deletions

File tree

‎docs/LANGUAGES.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
| PHP | `.php` | `php -l {file}` | `php-cs-fixer fix` | composer require --dev php-cs-fixer |
2121
| Ruby | `.rb` | `rubocop` | `rubocop -A` | gem install rubocop |
2222
| Scala | `.scala` `.sc` | `scalafmt --check .` | `scalafmt` | coursier install scalafmt |
23-
| Shell | `.sh` `.bash` `.zsh` | `shellcheck --severity=warning {file}` | `shfmt -w .` | brew install shellcheck shfmt |
23+
| Shell | `.sh` `.bash` | `shellcheck --severity=warning {file}` | `shfmt -w .` | brew install shellcheck shfmt |
2424
| Dockerfile | (文件名匹配) | `hadolint {file}` | `hadolint` | brew install hadolint |
2525
| YAML | `.yml` `.yaml` | `yamllint .` | `yamllint .` | pip install yamllint |
2626
| Elixir | `.ex` `.exs` | `mix credo --strict` | `mix format` | 项目需配置 credo 依赖 |

‎hooks/gate_lib.py‎

Lines changed: 43 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@
1818
import time
1919
from pathlib import Path
2020

21+
try: # Windows 无 fcntl——锁降级为可选,原子替换仍生效
22+
import fcntl
23+
except ImportError: # pragma: no cover
24+
fcntl = None
25+
2126
PLUGIN_ROOT = Path(__file__).resolve().parents[1] # hooks/ 的上级 = 插件根
2227
sys.path.insert(0, str(PLUGIN_ROOT / "scripts"))
2328

@@ -155,18 +160,23 @@ def _worktree_fingerprint(project_root: Path) -> str:
155160
h.update(("\n".join(names)).encode("utf-8", "replace"))
156161
if args[:2] == ("diff", "--name-only"):
157162
# 未暂存已跟踪文件:内容改动名字不变,必须叠加 stat 指纹
158-
# (staged 路不需要——暂存内容变化必动 index,由键内 idx_sig 兜底)
159-
for name in names[:500]:
163+
# (staged 路不需要——暂存内容变化必动 index,由键内 idx_sig 兜底)。
164+
# 名字集合不截断:截断会让第 N+1 个文件的修复在缓存窗口内被旧
165+
# 指纹掩盖(retry-timing 陷阱对大改动面复活)。stat 本身足够便宜。
166+
for name in names:
160167
try:
161168
st = (project_root / name).stat()
162169
h.update(f"{name}:{st.st_size}:{st.st_mtime_ns}".encode("utf-8", "replace"))
163170
except OSError:
164171
h.update(name.encode("utf-8", "replace"))
165172
# 未跟踪文件:ls-files 只给文件名——内容改动名字不变,必须叠加 stat 指纹
166173
others = (_git(project_root, "ls-files", "--others") or "").splitlines()
167-
for name in sorted(others)[:500]:
168-
if not name.strip():
169-
continue
174+
for name in sorted(n for n in others if n.strip()):
175+
try:
176+
st = (project_root / name).stat()
177+
h.update(f"{name}:{st.st_size}:{st.st_mtime_ns}".encode("utf-8", "replace"))
178+
except OSError:
179+
h.update(name.encode("utf-8", "replace"))
170180
try:
171181
st = (project_root / name).stat()
172182
h.update(f"{name}:{st.st_size}:{st.st_mtime_ns}".encode("utf-8", "replace"))
@@ -288,9 +298,13 @@ def run_gate(
288298

289299
if ck:
290300
with contextlib.suppress(OSError):
291-
cache_file.write_text(json.dumps(
301+
# 原子替换:UPS 与 PreToolUse 并发写同一 cache 文件时,
302+
# 裸 write_text 可能让对方读到半截 JSON(ValueError → 缓存失效重扫)。
303+
tmp = cache_file.with_name(f"{cache_file.name}.{os.getpid()}.tmp")
304+
tmp.write_text(json.dumps(
292305
{"key": ck, "ts": _time.time(), "failures": failures, "skipped": skipped},
293306
ensure_ascii=False))
307+
os.replace(tmp, cache_file)
294308
return failures, skipped
295309

296310

@@ -769,26 +783,33 @@ def should_suppress_event(key: str, window: float = 2.0) -> bool:
769783
import hashlib
770784
import time as _time
771785
path = codeguard_home() / "hook_dedup.json"
772-
now = _time.monotonic()
786+
now = _time.time()
773787
try:
774788
path.parent.mkdir(parents=True, exist_ok=True)
775-
dedup = {}
776-
if path.exists():
777-
dedup = json.loads(path.read_text(encoding="utf-8"))
778-
h = hashlib.sha1(key.encode("utf-8", "replace")).hexdigest()[:24]
779-
last = dedup.get(h) or {}
780-
stale = [k for k, v in dedup.items() if now - v.get("ts", 0) > 60]
781-
for k in stale:
782-
dedup.pop(k, None)
783-
if now - last.get("ts", 0) < window:
784-
dedup[h] = last
785-
path.write_text(json.dumps(dedup), encoding="utf-8")
786-
return True
787-
dedup[h] = {"ts": now}
788-
path.write_text(json.dumps(dedup), encoding="utf-8")
789+
# 并发安全:UPS 与 PreToolUse 两条钩子在数秒窗口内先后读写本文件,
790+
# 裸 read-modify-write 会互相覆盖(丢抑制事件 → 双份报告)或读到半截
791+
# JSON(ValueError → 去重静默失效)。进程锁 + tmp+replace 原子替换。
792+
# 时间戳用墙钟:monotonic 跨重启回绕,持久化文件不能存。
793+
lock_path = path.with_name(path.name + ".lock")
794+
with open(lock_path, "w") as lock:
795+
if fcntl:
796+
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
797+
dedup = {}
798+
if path.exists():
799+
dedup = json.loads(path.read_text(encoding="utf-8"))
800+
h = hashlib.sha1(key.encode("utf-8", "replace")).hexdigest()[:24]
801+
last = dedup.get(h) or {}
802+
stale = [k for k, v in dedup.items() if now - v.get("ts", 0) > 60]
803+
for k in stale:
804+
dedup.pop(k, None)
805+
suppress = now - last.get("ts", 0) < window
806+
dedup[h] = {"ts": last.get("ts", now)} if suppress else {"ts": now}
807+
tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
808+
tmp.write_text(json.dumps(dedup), encoding="utf-8")
809+
os.replace(tmp, path)
810+
return suppress
789811
except (OSError, ValueError):
790812
return False
791-
return False
792813

793814

794815
def summarize_failures(failures: list) -> str:

‎hooks/user_prompt_validator.py‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,22 @@ def is_trigger(user_text: str) -> bool:
8686
"""
8787
if not user_text:
8888
return False
89-
if any(q in user_text for q in QUESTION_MARKERS):
89+
# 问句抑制按**句子粒度**:`这个方案 OK 吗?帮我提交` 问句与祈使混排时,
90+
# 整条文本级的问号判断会把祈使句一起静默(软门缺失,硬门兜底,但漏提醒
91+
# 不符合"软门多提醒不算错"的既定原则)。
92+
sentences = [seg for seg in _re.split(r"[。!!??\n\r]+", user_text) if seg.strip()]
93+
trigger_sentences = [seg for seg in sentences if _TRIGGER_RE.search(seg)]
94+
if not trigger_sentences:
9095
return False
91-
m = _TRIGGER_RE.search(user_text)
96+
non_question = [seg for seg in trigger_sentences
97+
if not any(q in seg for q in QUESTION_MARKERS)]
98+
if not non_question:
99+
return False
100+
m = _TRIGGER_RE.search(non_question[0])
92101
if not m:
93102
return False
94103
# 句首祈使:触发词本身(或前面只有空白/常见介词)位于句首。
95-
stripped = user_text.lstrip()
104+
stripped = non_question[0].lstrip()
96105
leading = stripped[: m.start()].strip().lower()
97106
if leading in ("", "git", "to", "the", "a", "an", "帮我", "请", "请帮我"):
98107
return True

‎scripts/languages.json‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -369,8 +369,7 @@
369369
"name": "Shell",
370370
"extensions": [
371371
".sh",
372-
".bash",
373-
".zsh"
372+
".bash"
374373
],
375374
"markers": [],
376375
"status": "stable",

‎tests/run_all.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,7 @@ def counting(root, cfg, langs, **kw):
576576
"import gate_lib\n"
577577
"def _boom(*a, **k): raise RuntimeError('injected-failure')\n"
578578
"gate_lib.run_gate=_boom\n"
579+
"gate_lib.skip_gate_via_git_config=lambda *a, **k: False\n" # 免疫本机 skipGate 配置
579580
f"runpy.run_path({str(HOOKS / 'user_prompt_validator.py')!r}, run_name='__main__')\n"
580581
)
581582
r = subprocess.run([sys.executable, "-c", code], capture_output=True, check=False, text=True,

0 commit comments

Comments
 (0)