Claude Code に修正を頼むと、「完了しました」と返ってきます。テストも通ったと書いてある。ところが画面を開くと、直っていない。
そのたびに、もう一度説明して、もう一度直してもらう。何度か続くと、AIの報告を信じていいのか、自分の確かめ方が悪いのか、分からなくなってきます。
問題は、Claude Code の腕ではありません。「完了」を言葉で受け取っていることです。燃料計を見ずに「満タンにしました」を信じて、高速道路に乗っているのと同じです。
私は Claude Code に業務システム(Python / FastAPI / React / Vite)を作らせています。44モジュール、Claude Code に書かせた自動テストは237本です。このプロジェクトでは、証拠があるものだけを「進んだ」と数えるルールにしていて、新しいチャットを始めるたびに、作業の前に次の順で起動を確かめています。
- API
- 画面(ダッシュボード)
- scheduler(FastAPI の lifespan の中で回している定期処理)
この記事では、この3つを1回のコマンドで確かめて、結果をそのまま証拠として貼れる形にするスクリプトを、最小実装で書きます。サンプルは記事用に書いたもので、私のシステムのコードそのものではありません。コードはすべて手元で実行して、出力を確かめています。
なぜ「200 が返った」だけでは足りないのか
3つとも、HTTP のステータスが 200 なら大丈夫に見えます。実際には、200 のまま壊れている形が2つあります。
| 確認 | 200 なのに壊れている形 |
|---|---|
| 画面 | 古いビルドを配信している URL を開いている。直した版は別のポートにある |
| scheduler | API は応答しているが、定期処理のループが例外で止まっている |
1つ目は、開発中の画面と、前にビルドした画面が別のポートで両方動いているときに起きます。古い方を開けば、直したものが直っていないように見えます。私のプロジェクトでは「最新の画面の URL と、古いビルドを配信する URL を混同しない」を禁止事項として説明書に固定で書いています。
2つ目は、lifespan の中でタスクを起動しているときに起きます。タスクが途中で例外を出して止まっても、API は普通に 200 を返し続けます。
なので、このスクリプトでは200に加えて、画面は「最後にビルドした版か」、scheduler は「今も回っているか」まで見ます。
scheduler の状態を API から返す
まず、FastAPI 側で scheduler の状態を返すエンドポイントを作ります。ポイントは、起動時に「started」という文字を入れておくのではなく、問い合わせのたびに、タスクが生きているか・最後に回ったのはいつかを見ることです。
import asyncio
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from fastapi import FastAPI
TICK_SECONDS = 5 # 定期処理の間隔
async def scheduler_loop(app: FastAPI) -> None:
while True:
app.state.last_tick = datetime.now(timezone.utc)
# ここに定期処理(点検期限の確認など)を書く
await asyncio.sleep(TICK_SECONDS)
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.last_tick = None
app.state.scheduler_task = asyncio.create_task(scheduler_loop(app))
yield
app.state.scheduler_task.cancel()
app = FastAPI(lifespan=lifespan)
@app.get("/api/health")
def health() -> dict:
task = app.state.scheduler_task
last = app.state.last_tick
if task.done():
scheduler = "stopped" # ループが例外で落ちている
elif last is None:
scheduler = "starting"
elif (datetime.now(timezone.utc) - last).total_seconds() > TICK_SECONDS * 3:
scheduler = "stale" # タスクはあるが、回っていない
else:
scheduler = "started"
return {
"api": "ok",
"scheduler": scheduler,
"last_tick": last.isoformat() if last else None,
}
状態は4つです。
-
started:タスクが生きていて、最近回っている -
starting:まだ1回も回っていない -
stale:タスクはあるが、間隔の3倍以上回っていない -
stopped:タスクが終わっている(例外で落ちた)
起動時に started と書いておくだけだと、ループが落ちても started のままです。それでは「scheduler started」というログを信じるのと変わりません。
起動確認スクリプト
次に、API → 画面 → scheduler の順に確かめるスクリプトです。
画面のほうは、Vite のビルドが index.html から assets/index-<ハッシュ>.js を読み込むことを使います。手元の dist/index.html にあるファイル名と、URL から配信されている HTML の中のファイル名が同じなら、最後にビルドした版が出ています。
"""起動確認:API → 画面 → scheduler の順に、証拠を取って判定する。"""
import re
import sys
from pathlib import Path
import httpx
API_HEALTH = "http://127.0.0.1:8000/api/health"
DASHBOARD = "http://127.0.0.1:5173/"
DIST_INDEX = Path("frontend/dist/index.html") # 最後にビルドした画面
# Vite のビルドは assets/index-<ハッシュ>.js を index.html から読み込む
ASSET = re.compile(r'src="(/assets/index-[^"]+\.js)"')
def main() -> int:
results: list[tuple[str, bool, str]] = []
# 1. API
try:
r = httpx.get(API_HEALTH, timeout=5)
health = r.json() if r.status_code == 200 else {}
results.append(("API", r.status_code == 200, f"{API_HEALTH} -> {r.status_code}"))
except httpx.HTTPError as e:
health = {}
results.append(("API", False, f"{API_HEALTH} -> {type(e).__name__}"))
# 2. 画面:200 だけでなく「最後にビルドした版」が配信されているか
expected = ASSET.search(DIST_INDEX.read_text(encoding="utf-8"))
expected = expected.group(1) if expected else None
try:
r = httpx.get(DASHBOARD, timeout=5)
served = ASSET.search(r.text)
served = served.group(1) if served else None
ok = r.status_code == 200 and served is not None and served == expected
results.append(("画面", ok, f"{DASHBOARD} -> {r.status_code} / 配信中 {served} / 最新ビルド {expected}"))
except httpx.HTTPError as e:
results.append(("画面", False, f"{DASHBOARD} -> {type(e).__name__}"))
# 3. scheduler:API が返した状態を見る(API が落ちていたら判定しない)
if health:
state = health.get("scheduler")
results.append(("scheduler", state == "started", f"{state} / last_tick {health.get('last_tick')}"))
else:
results.append(("scheduler", False, "API が応答しないため未確認"))
for name, ok, evidence in results:
print(f"[{'OK' if ok else 'NG'}] {name:<9} {evidence}")
return 0 if all(ok for _, ok, _ in results) else 1
if __name__ == "__main__":
sys.exit(main())
ポートは、uvicorn と Vite の開発サーバーの既定値(8000・5173)にしています。下の「古いビルド」の例の 4173 は、vite preview の既定のポートです。URL とパスは自分の環境に合わせて書き換えてください。
実行結果
すべて動いているとき:
[OK] API http://127.0.0.1:8000/api/health -> 200
[OK] 画面 http://127.0.0.1:5173/ -> 200 / 配信中 /assets/index-Bx7kQ2.js / 最新ビルド /assets/index-Bx7kQ2.js
[OK] scheduler started / last_tick 2026-10-21T23:50:54.882949+00:00
古いビルドを配信している URL を見ていたとき(ステータスは 200):
[OK] API http://127.0.0.1:8000/api/health -> 200
[NG] 画面 http://127.0.0.1:4173/ -> 200 / 配信中 /assets/index-D93aLm.js / 最新ビルド /assets/index-Bx7kQ2.js
[OK] scheduler started / last_tick 2026-10-21T23:50:54.882949+00:00
定期処理のループが例外で止まっていたとき(API は 200):
[OK] API http://127.0.0.1:8000/api/health -> 200
[OK] 画面 http://127.0.0.1:5173/ -> 200 / 配信中 /assets/index-Bx7kQ2.js / 最新ビルド /assets/index-Bx7kQ2.js
[NG] scheduler stopped / last_tick 2026-10-21T23:51:06.892477+00:00
何も起動していないとき:
[NG] API http://127.0.0.1:8000/api/health -> ConnectError
[NG] 画面 http://127.0.0.1:5173/ -> ConnectError
[NG] scheduler API が応答しないため未確認
1つでも NG なら終了コードは 1 です。API が応答しないときは、scheduler を「NG(未確認)」にしています。確かめられなかったものを OK にしないためです。
使い方:作業の前と後に1回ずつ
私の運用では、このスクリプトに当たる確認を2か所で使います。
- 作業を始める前:全部 OK の状態でだけ作業を始める。壊れた状態の上で作業を始めると、新しく壊したのか、前から壊れていたのかが分からなくなる
- Claude Code が「完了しました」と言った後:出力をそのまま貼ってもらう。OK が並んでいなければ、完了として数えない
Claude Code への指示には、「最後に python check_startup.py を実行して、出力をそのまま貼る」と1行足すだけです。「動きました」という文章の代わりに、この3行が返ってきます。
NG が出たら、その行をどこから調べるかの手がかりにします。「たぶんこれが原因」で直させることはしません。
この起動確認は、Claude Code に仕事を任せる前に決めていることの1つです。AIに触らせない機能の抜き方や、任せる範囲を「取り返しがつくか」「誰に届くか」の2つの質問で決める方法など、作りながら決めた7つを、それぞれの詳しい記事へのリンクつきで note に1本にまとめています(無料で読めます)。