Agent PR 中 catch-all 返回空对象/None 常被误判为代码清理,实为产品行为变更;总结了三类高频模式:sentinel 转换、日志继续、重试无预算。
Agent 生成的 Pull Request 在成功路径上通常看起来已经完成。失败路径才是它们悄悄改变产品行为的地方。在合并前,将每一个新增的 except、catch、retry、timeout 和默认返回值分类为 Trust(可信任)、Revert(需回退)或 Test(需测试)。如果一个 handler 返回了哨兵值、日志后继续执行,或者在无预算限制的情况下重试,将其视为行为变更,而非清理工作。
这是一份审查协议,不是感觉检查。它基于统一 diff 加上一个简短的分类器运行。以下不涉及任何生产指标。示例标注为一种可本地运行的提议工作流。
Coding agent 的奖励来自于绿色测试和紧凑的 diff。吞掉错误是一种同时获得这两者的廉价方式。一个返回 {} 的 catch-all 通常会满足一个只断言 isinstance(result, dict) 的单元测试。
但公开契约已经移动了。调用方过去看到的是 ConnectionError,现在看到的是空 JSON。grep 特定异常名的 on-call playbooks 变成了盲区。这是一种伪装成健壮性的产品变更。
三种模式在 agent diff 中反复出现:
哨兵转换 — except Exception: return None / return [] / return {}
日志后继续 — 异常被格式化后,执行按成功流程继续
无界救援 — 重试循环、time.sleep,或没有截止日期的回退 HTTP 调用
成功路径的增量通常更值得信任。失败路径的增量需要一个命名契约。
用这张表做第一轮筛选。这是一个决策辅助工具,不是合并策略。
如果同一 hunk 触发了两个以上的行,采取更严格的动作。Revert 优先于 Test,Test 优先于 Trust。
提议的工作流。在人工审查前对 agent 分支运行它,而不是替代人工审查。
git fetch origin
git diff origin/main...HEAD > /tmp/agent.pr.diff
python3 failure_path_review.py /tmp/agent.pr.diff
#!/usr/bin/env python3
"""failure_path_review.py — classify failure-path hunks in a unified diff.
Proposed local review aid. It does not execute the patch and does not
prove correctness. Exit code 2 means at least one REVERT row fired.
"""
from __future__ import annotations
import re
import sys
from dataclasses import dataclass
from pathlib import Path
REVERT_PATTERNS = [
(r"except\s+Exception\b", "broad-except"),
(r"except\s*:", "bare-except"),
(r"catch\s*\(\s*Exception\b", "broad-catch"),
(r"except\b[^:]*:\s*(return|pass)\b", "swallow-return"),
(r"return\s+(\[\]|\{\}|None|0)\s*$", "sentinel-return"),
(r"time\.sleep\s*\(", "sleep-retry"),
(r"for\s+_\s+in\s+range\s*\(\s*\d+", "counted-retry"),
]
TEST_PATTERNS = [
(r"timeout\s*=", "timeout-kw"),
(r"logger\.(debug|info|warning|error|exception)", "log-on-failure"),
(r"raise\s+\w+", "reraise-or-wrap"),
(r"pytest\.raises|assertRaises", "negative-test"),
]
TRUST_PATTERNS = [
(r"except\s+(FileNotFoundError|json\.JSONDecodeError|KeyError)\b", "narrow-except"),
]
ADDED = re.compile(r"^\+(?!\+)")
HUNK_FILE = re.compile(r"^\+\+\+\s+b/(.+)$")
@dataclass
class Finding:
path: str
line: str
action: str
rule: str
def classify_added_line(path: str, line: str) -> Finding | None:
body = line[1:].strip()
if not body or body.startswith("#"):
return None
for pat, rule in REVERT_PATTERNS:
if re.search(pat, body):
return Finding(path, body, "REVERT", rule)
for pat, rule in TEST_PATTERNS:
if re.search(pat, body):
return Finding(path, body, "TEST", rule)
for pat, rule in TRUST_PATTERNS:
if re.search(pat, body):
return Finding(path, body, "TRUST", rule)
return None
def scan(diff_text: str) -> list[Finding]:
current = "<unknown>"
out: list[Finding] = []
for raw in diff_text.splitlines():
m = HUNK_FILE.match(raw)
if m:
current = m.group(1)
continue
if not ADDED.match(raw):
continue
found = classify_added_line(current, raw)
if found:
out.append(found)
return out
def main(argv: list[str]) -> int:
if len(argv) != 2:
print("usage: failure_path_review.py <unified.diff>", file=sys.stderr)
return 2
text = Path(argv[1]).read_text(encoding="utf-8", errors="replace")
findings = scan(text)
if not findings:
print("No failure-path signals in added lines.")
return 0
revert = 0
for f in findings:
print(f"{f.action:6} {f.rule:16} {f.path}: {f.line}")
if f.action == "REVERT":
revert += 1
print(f"-- {len(findings)} signal(s), {revert} revert")
return 2 if revert else 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
分类器对增量行做词法匹配。这是刻意的。审查者需要在阅读补丁前撒下一张廉价但有噪声的网。后续轮次可以忽略注释和字符串字面量;但这一轮不应该。
提议的示例。不是来自私有仓库的报告。
--- a/billing/fetch.py
+++ b/billing/fetch.py
@@ -18,7 +18,16 @@ def load_invoice(client, invoice_id):
- return client.get(f"/invoices/{invoice_id}").json()
+ try:
+ return client.get(f"/invoices/{invoice_id}", timeout=2).json()
+ except Exception:
+ logger.warning("invoice miss %s", invoice_id)
+ return {}
REVERT broad-except billing/fetch.py: except Exception:
REVERT sentinel-return billing/fetch.py: return {}
TEST timeout-kw billing/fetch.py: return client.get(f"/invoices/{invoice_id}", timeout=2).json()
TEST log-on-failure billing/fetch.py: logger.warning("invoice miss %s", invoice_id)
-- 4 signal(s), 2 revert
在任何其他讨论之前先拆分这个 hunk。仅在 HTTP 客户端之前没有截止日期且服务 SLO 允许 2 秒截断时保留 timeout=2。回退 except Exception 和 return {}。这两行将每次宕机、4xx、5xx 和 JSON 解析错误都转换成了空发票对象。
保留契约的替代方案:
def load_invoice(client, invoice_id):
response = client.get(f"/invoices/{invoice_id}", timeout=2)
response.raise_for_status()
return response.json()
如果缺失发票是文档化过的用例,给那个用例命名:
class InvoiceNotFound(LookupError):
pass
def load_invoice(client, invoice_id):
response = client.get(f"/invoices/{invoice_id}", timeout=2)
if response.status_code == 404:
raise InvoiceNotFound(invoice_id)
response.raise_for_status()
return response.json()
第二个版本更长。但它也是可审查的。除非审查评论中禁止了哨兵转换,否则 agent 会优化成第一个版本。
没有 agent 写的负面测试,失败路径审查是不完整的。发票示例的最小集合:
传输失败 — stub client.get 抛出 ConnectionError;期望相同的异常类型(或文档化的包装器),而不是 {}。
未找到 — stub HTTP 404;期望 InvoiceNotFound,而不是 HTTP 200 语义。
超时 — stub 超过 2 秒的延迟;期望客户端超时错误。
解析失败 — stub HTTP 200 且 body 为 "nope";期望 JSONDecodeError 或命名的解析错误。
用 fake client 勾勒测试:
class FakeResp:
def __init__(self, status_code, payload, json_error=False):
self.status_code = status_code
self._payload = payload
self._json_error = json_error
def raise_for_status(self):
if self.status_code >= 400:
raise RuntimeError(f"http {self.status_code}")
def json(self):
if self._json_error:
raise ValueError("invalid json")
return self._payload
def test_404_does_not_become_empty_dict():
client = type("C", (), {"get": staticmethod(lambda *a, **k: FakeResp(404, {}))})()
try:
load_invoice(client, "inv_1")
except (InvoiceNotFound, RuntimeError):
return
raise AssertionError("404 was converted into a successful empty invoice")
如果 agent PR 已经包含一个测试文件,在看实现前先读断言。一个只在补丁异常后检查 assert result == {} 的测试,是失败契约被改写成匹配 handler 的证据。
粘贴到 hunk 下方,然后填充空白。简短的评论才会被执行;长篇大论不会。
Failure-path audit:
- Signal: [broad-except | sentinel-return | retry | timeout | wrap]
- Action: [REVERT | TEST | TRUST]
- Contract before this PR: [exception type / status code / empty]
- Contract after this PR: [exception type / status code / empty]
- Required test: [transport | 4xx | timeout | parse]
- Retry/sleep allowed?: no, unless idempotency key is cited in this thread
要求 agent(或作者)在同一 thread 中回答 contract-before / contract-after 两行。如果这两行相同,handler 可以保留。如果不同,PR 描述必须用一句话说明。
审计是本地化的且与工具无关。它只需要 git diff 和 Python 3。
如果 PR 本身是由 agent 循环产生的,将那个循环保持在持有生产凭证的笔记本之外。披露:本文是 MonkeyCode 产品推广的一部分准备的。MonkeyCode 提供免费模型访问和免费服务器选项,这足以在隔离的工作区中生成候选补丁,然后将 diff 导出到 failure_path_review.py。分类器不调用该产品,也不依赖它。
不要将 secrets、客户 payload 或生产 URL 粘贴到那个工作区。审查仍在导出的 diff 上进行。
词法匹配会漏掉 except (OSError, TimeoutError) as exc: 这种实际上是正确的情况,也会对非错误辅助函数中的 return {} 发出警报。
它不理解 Go 的 if err != nil、Rust 的 ? 或 Java 的 checked exceptions,除非你添加对应的模式。
它看不到位于已删除行上的已删除 raise 语句。将其与 git diff -U5 配对,并对以 - 开头的行进行扫描。
超时值、重试次数和 sleep 时长需要领域 SLO。该脚本不会知道 timeout=2 是否安全。
一次干净的分类器运行不是安全审查。它不会在同一 PR 中捕获 SSRF、路径遍历或 prompt 注入。
在以下情况下,不要仅基于此脚本做出合并决定:
在这些情况下,先在 PR 正文中写明失败契约,然后再生成代码。事后审计适用于默认的 agent PR,即没有人写过那份契约的情况。
保留 try。回退那些虚构成功的 except。测试每一个 timeout 和每一个重命名的错误。如果分类器打印出 REVERT,人类审查从那里开始,而不是从 agent 同时重写的 README 开始。