3行で
- 自分のセッションログ 9,906 件のツール呼び出しを調べたら、失敗フラグが立っていたのは 283 件(2.9%)だった。
- その内訳は 統治 33.6% / 実失敗 34.3% / 「出力はあるのに exit≠0」21.2% / 不明 11.0%。失敗のうち実際に壊れていたのは3分の1。
- しかも失敗フラグは両方向に嘘をつく。成功したコマンドが失敗と記録され、失敗したコマンドが成功と記録される。「エラー率」を運用指標にすると、直す価値のないものを直すことになる。
なぜ分類したか
エージェントの運用を改善しようとすると、まず「エラー率」を見たくなる。私も見た。2.9% という数字が出た。
そこで止まると何も分からない。2.9% を 1.5% にすることに意味があるのか、そもそもこの 283 件は「直せるもの」なのか。分母も分子も、中身を見るまでは何を数えているのか決まっていない。
だから 283 件を全部開いて、文面で分けた。
何を数えたか
Claude Code はセッションを ~/.claude/projects/**/*.jsonl に行区切り JSON で残す。ツールの結果は type: "tool_result" のブロックで、失敗すると is_error: true が立つ。
ひとつ厄介なのは、tool_result にツール名が入っていないことだ。名前は直前の tool_use ブロックにあり、tool_use_id で紐づく。だから走査中に id → 名前の対応を持ち歩く必要がある。
if blk.get("type") == "tool_use":
pending[blk.get("id")] = blk.get("name")
elif blk.get("type") == "tool_result":
name = pending.get(blk.get("tool_use_id"), "?")
分類の規則は全部正規表現で、記事末尾のスクリプトにそのまま入っている。実行するとこう出る。
ツール結果 9,906 件 / 失敗 283 件 = 2.9%
ツール別の失敗率(30件以上)
Bash 222 / 6394 = 3.5%
Read 25 / 1332 = 1.9%
Edit 12 / 1239 = 1.0%
Write 2 / 172 = 1.2%
WebFetch 1 / 146 = 0.7%
WebSearch 0 / 100 = 0.0%
Grep 1 / 99 = 1.0%
StructuredOutput 4 / 88 = 4.5%
ToolSearch 0 / 78 = 0.0%
Agent 0 / 66 = 0.0%
AskUserQuestion 0 / 40 = 0.0%
Glob 3 / 35 = 8.6%
失敗の内訳
統治 95 33.6%
cd+git 誘導 34
承認要求 14
複合コマンド分解 13
分類器が拒否 11
スキーマ違反 9
ハーネスが制止 6
worktree 隔離 4
未読ファイル編集 3
ユーザーが却下 1
実失敗 97 34.3%
対象が無い 38
実行時エラー 32
タイムアウト 17
外部API 10
見かけ 60 21.2%
出力はあるが exit≠0 60
不明 31 11.0%
不明 31
以下、上から見ていく。
統治 95件 — これは失敗ではなく、実行されていない
3分の1は「エージェントが何かを間違えた」ではなく、ハーネスが実行前に止めたものだった。コマンドは走っていない。だから直す対象は「コマンドの中身」ではなく「呼び方」になる。
最多は 34 件で、文面はこうだ。
This command changes directory before running git, which can execute
untrusted hooks from the target directory. Approve only if you trust it.
cd somewhere && git ... という形を止めている。理由は書いてあるとおりで、移動先の .git/hooks/ は移動先のものだからだ。信頼していないディレクトリへ cd してから git を叩くのは、そのディレクトリのフックに実行権を渡すのと同じになる。
これは git -C で消える。
# 止まる
cd /path/to/repo && git log --oneline -5
# 止まらない
git -C /path/to/repo log --oneline -5
私のコーパスで cd 始まりかつ git を含むコマンドは 1,798 件あった。対して git -C を使っていたのは 180 件。10 対 1 で悪いほうを書いている。
ただし正確に言うと、1,798 件のうち発火したのは 34 件(1.9%)だけだ。ゲートは cd+git を機械的に全部止めているわけではない。それでも、止まった 34 件は失敗全体の最多単一原因だった。1.9% しか発火しない条件が最多を占めるということは、それ以外の原因がいかに散らばっているかということでもある。
2番目以降も性質は同じだ。
- 承認要求 14件 — 素で承認待ちになった。
-
複合コマンド分解 13件 —
&&や改行で繋いだ塊のうち、一部だけが承認対象だと判定された。This Bash command contains multiple operations. The following part requires approval:と、どの部分かまで示される。 - 分類器が拒否 11件 — 自動承認モードの分類器がブロックした。
-
スキーマ違反 9件・worktree 隔離 4件・未読ファイル編集 3件 — 規約違反。
Editの前にReadしていない、といった作法の話。
95 件すべてに共通するのは、もう一度同じことを、違う書き方で頼めば通ることだ。エラーではなく、通行許可の問題になる。
実失敗 97件 — 内訳は退屈で、だからこそ直せる
こちらが本物。最多は「対象が無い」38 件。
Read の失敗 25 件のうち 24 件(96%)が File does not exist だった。つまり Read はほぼ壊れない。壊れるのは、その前に「ファイルがそこにあるはずだ」と決めたところ。
Edit の 12 件も似た構図で、5 件が String to replace not found in file。ファイルは開けている。置換対象の文字列のほうが、思っていた形と違った。
実行時エラー 32 件は素直な Traceback と SyntaxError。タイムアウト 17 件、外部 API 10 件(レート制限・401)。
ここには驚きがない。驚きがないことが重要で、実失敗はすべて「対象の状態についての思い込み」に還元される。存在すると思ったファイル、この形だと思った文字列、間に合うと思った処理。
見かけ 60件 — 出力はあるのに exit≠0
一番おもしろかったのがここ。エラーらしい語(error / fatal / no such / denied …)が本文に一切なく、正常な出力だけを返しているのに is_error が立っているものが 60 件あった。平均 578 文字の、ふつうの実行結果が入っている。
原因はシェルの終了コードの規則だ。; で連ねたコマンド列の終了コードは、最後のひとつだけで決まる。
$ bash -c 'ls d; echo "--- 次 ---"; grep -c PATTERN d/a.txt'; echo "exit=$?"
a.txt
b.txt
--- 次 ---
0
exit=1
ls は成功している。echo も成功している。最後の grep -c が「0 件でした」と正しく報告し、grep は「見つからなかった」を終了コード 1 で表す。それだけで、この呼び出し全体が失敗として記録される。
順番を入れ替えるだけで消える。
$ bash -c 'grep -c PATTERN d/a.txt; ls d'; echo "exit=$?"
0
a.txt
b.txt
exit=0
中身は同じ。やったことも同じ。記録だけが違う。
そして逆方向にも嘘をつく。パイプの終了コードも最後のひとつで決まるので、失敗が握り潰される。
$ bash -c 'ls 存在しない | cat'; echo "exit=$?"
exit=0
ls は失敗している。cat が成功したので全体は成功。これは失敗として記録されない。つまり私の 283 件という数字は、こちら側では過小になっている。
拾いたければ pipefail を立てる。
$ bash -c 'set -o pipefail; ls 存在しない | cat'; echo "exit=$?"
exit=1
私のコーパスでは Bash コマンドの 88.2% が複合(改行・パイプ・&& のいずれかを含む)だった。複合が支配的な環境で、終了コードは最後の 1 コマンドの意見でしかない。この条件下の「エラー率」は、測っているつもりのものを測っていない。
それで「エラー率 2.9%」は何だったのか
分けたあとで元の数字に戻ると、こうなる。
| 見え方 | 件数 | 率 | 直し方 |
|---|---|---|---|
| 生の失敗フラグ | 283 | 2.9% | — |
| うち統治(実行されていない) | 95 | 1.0% | 呼び方を変える |
| うち実失敗 | 97 | 1.0% | 対象の確認を増やす |
| うち見かけ(出力はある) | 60 | 0.6% | 直す対象ではない |
| うち不明 | 31 | 0.3% | — |
改善対象は 2.9% ではなく 1.0%。そして統治の 1.0% は、コードの品質ではなく git -C と書くかどうかで決まる。この2つは原因も対処も無関係なのに、ひとつの指標に混ぜられていた。
さらに、パイプで握り潰された失敗はこの表のどこにも出てこない。分子は水増しされ、同時に取りこぼしている。
エラー率を運用指標にするなら、分類してからでないと使えない。少なくとも「統治」を分母から抜き、pipefail を入れて分子を正しくしてからでないと、下げても上げても意味が読めない。
自分のログで確かめる
以下を classify_failures.py として保存して実行する。引数なしで ~/.claude/projects を全部読む。
#!/usr/bin/env python3
"""Claude Code のセッションログから、ツール呼び出しの失敗を分類する。
python3 classify_failures.py [~/.claude/projects]
"""
import sys, os, glob, json, re, collections
GOVERNANCE = [
("cd+git 誘導", r"changes directory before running git"),
("承認要求", r"^This command requires approval"),
("複合コマンド分解", r"contains multiple operations"),
("分類器が拒否", r"Permission for this action was denied"),
("ユーザーが却下", r"doesn't want to proceed"),
("ハーネスが制止", r"Blocked:"),
("未読ファイル編集", r"File has not been read yet"),
("worktree 隔離", r"isolated in the worktree|EnterWorktree cannot"),
("スキーマ違反", r"InputValidationError|does not match required schema"),
]
REAL = [
("対象が無い", r"^File does not exist|String to replace not found|not a git repository|already checked out"),
("実行時エラー", r"Traceback \(most recent call last\)|SyntaxError|Exit code 128 fatal"),
("タイムアウト", r"Command timed out|timed out after \d+ second"),
("外部API", r"rate limit exceeded|401 Unauthorized|channel_not_found|non-200"),
]
# エラーらしい語が本文に一切無いなら、出力は返っている=「見かけの失敗」
ERRISH = re.compile(r"(?i)error|fatal|no such|not found|denied|invalid|usage:|warning")
def texts(root):
"""(tool_name, is_error, body) を全セッションから取り出す。"""
for path in glob.glob(os.path.join(root, "**", "*.jsonl"), recursive=True):
pending = {}
with open(path, encoding="utf-8", errors="replace") as fh:
for line in fh:
try:
ev = json.loads(line)
except ValueError:
continue
content = (ev.get("message") or {}).get("content")
if not isinstance(content, list):
continue
for blk in content:
if not isinstance(blk, dict):
continue
# tool_use_id からツール名を引けるようにしておく
if blk.get("type") == "tool_use":
pending[blk.get("id")] = blk.get("name")
elif blk.get("type") == "tool_result":
body = blk.get("content")
if isinstance(body, list):
body = " ".join(b.get("text", "") for b in body
if isinstance(b, dict))
yield (pending.get(blk.get("tool_use_id"), "?"),
bool(blk.get("is_error")), str(body or ""))
def classify(body):
for label, pat in GOVERNANCE:
if re.search(pat, body):
return "統治", label
for label, pat in REAL:
if re.search(pat, body):
return "実失敗", label
if not ERRISH.search(body[:200]):
return "見かけ", "出力はあるが exit≠0"
return "不明", "不明"
def main():
root = os.path.expanduser(sys.argv[1] if len(sys.argv) > 1 else "~/.claude/projects")
total = fails = 0
by_tool, err_tool = collections.Counter(), collections.Counter()
buckets, detail = collections.Counter(), collections.Counter()
for tool, is_err, body in texts(root):
total += 1
by_tool[tool] += 1
if not is_err:
continue
fails += 1
err_tool[tool] += 1
kind, label = classify(" ".join(body.split()))
buckets[kind] += 1
detail[(kind, label)] += 1
if not total:
print("ツール結果が見つからない。パスを確認する。")
return 1
print(f"ツール結果 {total:,} 件 / 失敗 {fails} 件 = {fails / total * 100:.1f}%\n")
print("ツール別の失敗率(30件以上)")
for tool, n in by_tool.most_common():
if n >= 30:
print(f" {tool:<18}{err_tool[tool]:>4} /{n:>6} = {err_tool[tool] / n * 100:>5.1f}%")
print("\n失敗の内訳")
for kind in ("統治", "実失敗", "見かけ", "不明"):
n = buckets[kind]
if not n:
continue
print(f" {kind:<6}{n:>4} {n / fails * 100:>5.1f}%")
for (k, label), m in detail.most_common():
if k == kind:
print(f" {label:<20}{m:>4}")
return 0
if __name__ == "__main__":
sys.exit(main())
正規表現は私のログから作ったので、そのままでは「不明」が多く出るかもしれない。その場合は推測で埋めずに、不明の文面を実際に開いて規則を足す。 私も最初の版で 50% が不明になり、開いてみて初めて承認ゲートが最大勢力だと分かった。
直す
計測の結果から出てくる手は3つだけだった。
1. cd X && git を git -C X にする。 統治の最多原因が消える。ついでに cd は shell の状態を変えるので、次の呼び出しの前提も壊す。
2. 複合コマンドの末尾に「終了コードを決めるつもりのないコマンド」を置かない。 grep -c や diff は、正常な報告のために非ゼロを返す。最後に置くなら意図を明示する。
# 末尾の grep が exit を決めてしまう
ls d; grep -c PATTERN d/a.txt
# 意図を書く
ls d; grep -c PATTERN d/a.txt || true
3. パイプを使うなら set -o pipefail。 入れないと失敗が消える。私のログでは複合が 88.2% なので、これは例外的な話ではない。
この記事の限界
- 単一のコーパスである。私の 35 プロジェクト・9,906 ツール呼び出しから出た比率で、他人の作業配分ではまったく違う分布になる。特に統治の比率は、承認モードの設定で直接動く。
- 分類は正規表現で、文面が変われば壊れる。ハーネスの警告文はバージョンで変わる。
- 「見かけ」の判定は近似である。「エラーらしい語が本文にない」を根拠にしているので、エラーメッセージが日本語だけの場合などは取りこぼす。60 件は下限として読んでほしい。
- パイプで握り潰された失敗は数えられていない。 数えるには過去のコマンドを再実行するしかなく、それは別の話になる。
まとめ
失敗フラグは、失敗の代理変数として弱い。
- 3分の1は実行されていない(統治)。コードではなく呼び方の問題で、
git -Cひとつで最多原因が消える。 - 3分の1が本物で、その全部が「対象の状態についての思い込み」に還元される。
- 2割は成功しているのに失敗と記録されたもの。
;で繋いだ末尾のコマンドが決めている。 - そして逆に、パイプの途中で死んだものは成功として記録されている。
数える前に分ける。分けないと、直す価値のないものを直すことになる。
関連
失敗の3分の1は「統治」── 規約に止められて実行されていないものでした。止めるべきものを止めているのか、それとも書き方のせいで通らないだけなのかは、分類しないと分かりません。
止める場所の設計と、規約が効かなくなる4つのパターンをまとめた本があります。
→ AIコンテキスト設計ガイド ── 規約を配ったのに守られない理由(2,000円)
第3章「規約が効かなくなる4つのパターン」まで無料で読めます。
/compact の後もルートの CLAUDE.md は再注入されるが、サブディレクトリと paths: 付きは再注入されない ── 長いセッションで静かに落ちるのはどちらか、という章です。