0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Codeの「完了しました」を証拠で確かめる:API→画面→schedulerの起動確認スクリプト(FastAPI + Vite)

0
Posted at

Claude Code に修正を頼むと、「完了しました」と返ってきます。テストも通ったと書いてある。ところが画面を開くと、直っていない。

そのたびに、もう一度説明して、もう一度直してもらう。何度か続くと、AIの報告を信じていいのか、自分の確かめ方が悪いのか、分からなくなってきます。

問題は、Claude Code の腕ではありません。「完了」を言葉で受け取っていることです。燃料計を見ずに「満タンにしました」を信じて、高速道路に乗っているのと同じです。

私は Claude Code に業務システム(Python / FastAPI / React / Vite)を作らせています。44モジュール、Claude Code に書かせた自動テストは237本です。このプロジェクトでは、証拠があるものだけを「進んだ」と数えるルールにしていて、新しいチャットを始めるたびに、作業の前に次の順で起動を確かめています。

  1. API
  2. 画面(ダッシュボード)
  3. 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」という文字を入れておくのではなく、問い合わせのたびに、タスクが生きているか・最後に回ったのはいつかを見ることです。

app.py
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 の中のファイル名が同じなら、最後にビルドした版が出ています。

check_startup.py
"""起動確認: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か所で使います。

  1. 作業を始める前:全部 OK の状態でだけ作業を始める。壊れた状態の上で作業を始めると、新しく壊したのか、前から壊れていたのかが分からなくなる
  2. Claude Code が「完了しました」と言った後:出力をそのまま貼ってもらう。OK が並んでいなければ、完了として数えない

Claude Code への指示には、「最後に python check_startup.py を実行して、出力をそのまま貼る」と1行足すだけです。「動きました」という文章の代わりに、この3行が返ってきます。

NG が出たら、その行をどこから調べるかの手がかりにします。「たぶんこれが原因」で直させることはしません。


この起動確認は、Claude Code に仕事を任せる前に決めていることの1つです。AIに触らせない機能の抜き方や、任せる範囲を「取り返しがつくか」「誰に届くか」の2つの質問で決める方法など、作りながら決めた7つを、それぞれの詳しい記事へのリンクつきで note に1本にまとめています(無料で読めます)。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?