FRL 自由研究ラボ

AIエージェントことはじめ — LAB 016

どこまで回っているかを測る(前編)

— 段階に分けて、残った跡を数える —

夜に AI が回り始めると、次に困るのは「本当に回っているのか」が分からないことです。 止まっても知らせは来ませんし、「回っているはず」という感覚は当てになりません。 この記事では、テストの有無・実行ログ(動いた記録)・予約(決めた時刻に自動で動かす設定)・最後に動いた日といった残った跡から、 いま何段目まで届いているかを数える小さな測定スクリプトを、手元で試しながら作ります。 前編では、「回っている」を段階に分け、測る材料を決めて、測定スクリプトを走らせるところまで進めます。

約35分

全部やる場合の目安

3 STEP

手順の数

4 段階

このスクリプトが測る段数

この記事の道順

  1. はじめに — 「回っているつもり」を疑う
  2. STEP 1 — 「回っている」を段階に分ける
  3. STEP 2 — 測る材料を「残る跡」に決める
  4. STEP 3 — 測定スクリプトを置いて走らせる
  5. 後編へ

※ LAB 010(_selftest.py と終了コード)と LAB 014(夜の実行スクリプト ~/night/run.py と cron の予約)を 終えている前提です。測る相手は、その2つで作ったものです。 「道具」は、LAB 009 で作ったような自作の小さなプログラムのことです。 道具を置いているフォルダは、この記事では ~/tools を例にしています(LAB 010 でテストを置いた場所に読み替えてください)。例は Mac のターミナルです。

INTRO

「回っているつもり」を疑う

LAB 014 で、夜に AI を働かせる仕組みができました。 ただ、仕組みを作ったことと、それが回り続けていることは別の話です。 LAB 014 で見たとおり、AI の操作が許可されず止められても、エラーにならず、正常終了に見えます。 止まっていても、画面は静かなままです。

「回っているかどうか」を感覚で判断すると、次の3つの罠にはまります。

① 止まっても知らせが来ない

静かなのは「順調」なのか「止まっている」のか、画面からは区別が付かない。→ 画面ではなく、残った跡を見る

② 装置があるだけで「動いている」と思う

予約を入れた、スクリプトを置いた。それは「動かせる」であって「動いた」ではない。→ 実際に動いた跡があるときだけ「稼働」と呼ぶ

③ 途中が切れているのに、上だけ動いて見える

土台のテストが無いのに、夜の実行だけは回っている、など。→ 1段目から順に見て、途切れた所で止めて数える

✕ 1回きりの診断書 診断書を 1回だけ書く 翌日には 現実とずれる ずれに 気づけない ○ 開くたびに数え直す 測定を 走らせる 跡を数える (ファイル・ログ・予約) いまの到達段数が その場で出る 数える材料は、テストの数・最後に動いた日など、日々変わるものにします。

筆者は 2026-08-16 に、この考え方で「ループ計測」という自作の画面(AI の作業が、人の手を借りずに繰り返し回っている度合いを測る画面)を作りました。 一度きりの診断書にすると翌日には古くなるので、開くたびにコードとログを数え直す形にしています。 この記事は、その考え方をいちばん小さい形に縮めたものです。

01

STEP 1 — 目安 10分

「回っている」を段階に分ける

「回っているか、いないか」の2択だと、どこが欠けているのか分かりません。 そこで、AI が自分で回り続けるまでの道のりを段階に分け、いま何段目まで届いているかを見ます。 筆者の画面(ループ計測)では、次の6段階です。4・5段目の「どう満たすか」は、筆者はタスクキュー(自作の自動実行の画面)で、この記事では LAB 014 の道具で満たす、という違いがあります。

段階 どういう状態か
1 人が読んで判定する AI が「できました」と書き、人が読んで正しいか決める。まだループではない(出発点)
2 機械が合否を判定できる _selftest.py が終了コード(0 なら成功を表す番号)で答える。LAB 010 で作ったもの
3 合否が1つのコマンドに束ねてある 全部のテストを1つの入口で走らせ、終了コード 0 だけを約束する
4 落ちたら自動でやり直す 完了条件(合否を決める検査)が落ちたら、出力を渡して AI に直させる。筆者の画面は、タスクキューの「完了条件」を使った実績を数える。この記事の道具では LAB 015の run.py の「2回やる」部分
5 自分で起動して回り続ける 人が押さなくても、時刻が来れば自動で走る。筆者の画面は、タスクキューの自動実行が ON で、最終実行が3日以内のときだけ稼働とする。この記事では LAB 014 の cron
6 別の役が検査する 作った AI とは別の「検査役」(作った本人以外の目で見る役)が点検する

数え方のルールは2つだけ

① 連続して届いた所まで

2段目から順に見て、最初に途切れた所で数えるのをやめます。6段目だけ動いていても「6段階まで来た」とは言いません。ループは途中が切れていると回らないからです。

② 装置だけでは「稼働」にしない

状態は3つ。稼働(実際に動いた跡がある)/装置のみ(仕組みはあるが使われていない)/未着手。筆者の画面では、自動実行が ON でも最後の実行が3日より前なら稼働と呼びません。

1段目 人が読んで 判定する 出発点 2段目 機械が合否を 出せる 稼働 3段目 1つの入口に 束ねてある 稼働 4段目 落ちたら やり直す 稼働 5段目 自分で起動して 回り続ける 止まっている 6段目 別の役が 検査する 稼働 ここまで連続して届いている → 到達は「4段階」 5段目で途切れているので、6段目が動いていても数えません。 直すのは、途切れた5段目です。

この記事で作る測定スクリプトは、LAB 014・015 で作ったものに合わせて、上の6段階を4段階に縮めます (STEP 2 で対応を示します)。段階の数は、自分の仕組みに合わせて決めてかまいません。

まず、いま自分の仕組みが何段目まであるのかを、AI に整理させます。何も変更させず、読むだけです。

次の6段階について、私のフォルダ(~/tools と ~/night)にそれぞれ何があるか、
ファイル名を挙げて整理して。無いものは「無い」と書いて。
1 人が読んで判定する
2 機械が合否を判定できる(_selftest.py)
3 合否が1つのコマンドに束ねてある
4 落ちたら自動でやり直す(完了条件)
5 自分で起動して回り続ける(cron の予約)
6 別の役が検査する(検査役)
ファイルの作成や設定の変更は、しないで。

✓ ここまでの確認: 6段階の意味を言い換えられる。「連続して届いた所まで」「装置だけでは稼働にしない」の2つのルールを、自分の言葉で説明できる。

02

STEP 2 — 目安 10分

測る材料を「残る跡」に決める

測るときの大原則は、AI に「いま何段目まで回っている?」と聞かないことです。 AI の答えは「できました」と同じ申告で、確かめた結果ではありません(LAB 010)。 代わりに、動いたら必ず残るもの(ファイル・ログ・予約・日付)を、プログラムに数えさせます。

このスクリプトが測る4段階と、見る跡は次のとおりです。

段階(上の6段階との対応) 見る跡 稼働 装置のみ
① 機械が合否を出せる(6段階の2) 道具ごとのフォルダ(~/tools/道具名/)の中に _selftest.py があるか 1つ以上ある (なし。あるか無いかだけ)
② 完了条件つきで実行している(4) 夜のログに「結果:」の行があるか 合否の出たログがある run.py はあるが、まだ走った跡が無い
③ 予約が入っている(5の前半) crontab -l の中身 night/run.py を呼ぶ行がある (なし)
④ 予約どおり最近動いている(5の後半) cron.log と、最新の合否ログの更新日 予約があり、両方が8日以内に更新されている 予約はあるが、更新が古い

測る前に、材料が実際にあるかを目で確かめます。道具のフォルダが ~/tools でなければ、STEP 3 のスクリプト先頭の TOOLS を、自分の場所に直します。

ls ~/tools
ls ~/night ~/night/log
crontab -l

どの跡も無い段は、それで正しい(まだ作っていない)です。無いまま測っても、「未着手」と出るだけで、壊れるものはありません。

✓ ここまでの確認: 4つの段それぞれについて、どのファイル・ログ・予約を見るかを言える。 上の3つのコマンドを実行して、何が出て何が出ないかを確かめた。

03

STEP 3 — 目安 15分 / この記事の中心

測定スクリプトを置いて走らせる

STEP 2 の4つの跡を数えて、段ごとの判定と、到達段数を表示する小さなスクリプトです。 やることは跡を読むだけで、何も書き換えません。

道具のフォルダ _selftest.py があるか 夜のログ 「結果:」の行があるか cron の予約と cron.log 予約の有無と最終更新日 measure.py 跡を段ごとに判定 稼働 / 装置のみ / 未着手 を段ごとに表示 到達は何段階か 読むだけです。何も書き換えず、終了コードも常に 0(測るだけで、合否にはしません)。

3-1 スクリプトを置く

ターミナルに貼ってください。Python は LAB 014 と同じく、Mac 付属の /usr/bin/python3 を使います。

cat > ~/night/measure.py <<'EOF'
#!/usr/bin/python3
import glob
import os
import re
import subprocess
import sys
import time

HOME = os.path.expanduser("~")
TOOLS = os.environ.get("LOOP_TOOLS", os.path.join(HOME, "tools"))   # 道具を置いているフォルダ
NIGHT = os.environ.get("LOOP_NIGHT", os.path.join(HOME, "night"))   # LAB 014 の夜の仕組み
FRESH_DAYS = float(os.environ.get("LOOP_FRESH_DAYS", "8"))          # 週1回の予約なら、余裕を見て8日
RESULT = re.compile(r"^結果: (合格|不合格)", re.M)                    # run.py が最後に書く行(行頭だけ)


def read(path):
    try:
        with open(path, encoding="utf-8", errors="ignore") as f:
            return f.read()
    except OSError:
        return ""


def age_days(path):
    try:
        return (time.time() - os.path.getmtime(path)) / 86400
    except OSError:
        return None


def cron_lines():
    try:
        out = subprocess.run(["crontab", "-l"], capture_output=True, text=True, timeout=10).stdout
    except (OSError, subprocess.SubprocessError):
        return []
    return [x for x in out.splitlines() if x.strip() and not x.lstrip().startswith("#")]


def measure():
    dirs = []
    if os.path.isdir(TOOLS):
        dirs = [d for d in sorted(os.listdir(TOOLS))
                if os.path.isdir(os.path.join(TOOLS, d)) and not d.startswith(".")]
    tested = [d for d in dirs if os.path.exists(os.path.join(TOOLS, d, "_selftest.py"))]

    logs = sorted(glob.glob(os.path.join(NIGHT, "log", "run-*.log")))
    verdicts = {p: RESULT.findall(read(p)) for p in logs}
    judged = [p for p in logs if verdicts[p]]
    passed = [p for p in judged if verdicts[p][-1] == "合格"]
    run_age = min([age_days(p) for p in judged], default=None)
    has_script = os.path.exists(os.path.join(NIGHT, "run.py"))

    cron = [x for x in cron_lines() if "night/run.py" in x]
    age = age_days(os.path.join(NIGHT, "log", "cron.log"))
    fresh = (age is not None and age <= FRESH_DAYS
             and run_age is not None and run_age <= FRESH_DAYS)

    s = []
    s.append(("機械が合否を出せる", "live" if tested else "off",
              "テストを持つ道具 %d / %d" % (len(tested), len(dirs))))
    s.append(("完了条件つきで実行している",
              "live" if judged else ("idle" if has_script else "off"),
              "合否の出たログ %d 件(うち合格 %d)" % (len(judged), len(passed))))
    s.append(("予約が入っている", "live" if cron else "off",
              "cron の予約 %d 本" % len(cron)))
    s.append(("予約どおり最近動いている",
              "live" if (cron and fresh) else ("idle" if (cron or age is not None) else "off"),
              "cron.log " + ("%.2f 日前" % age if age is not None else "なし")
              + "/最新の合否ログ " + ("%.2f 日前" % run_age if run_age is not None else "なし")
              + "(どちらも %g 日以内なら稼働)" % FRESH_DAYS))
    return s


def main():
    label = {"live": "稼働", "idle": "装置のみ", "off": "未着手"}
    stages = measure()
    for i, (name, status, fact) in enumerate(stages, start=1):
        print("%d段目 [%s] %s … %s" % (i, label[status], name, fact))
    for i, (name, status, fact) in enumerate(stages, start=1):
        if status != "live":
            print("到達: %d / %d 段階(%d段目「%s」で途切れている)" % (i - 1, len(stages), i, name))
            return 0
    print("到達: %d / %d 段階(すべてつながっている)" % (len(stages), len(stages)))
    return 0


if __name__ == "__main__":
    sys.exit(main())
EOF

3-2 手で1回走らせる

/usr/bin/python3 ~/night/measure.py

次のような表示が出ます(数字は例で、自分の環境の結果が正解です)。

1段目 [稼働] 機械が合否を出せる … テストを持つ道具 3 / 4
2段目 [稼働] 完了条件つきで実行している … 合否の出たログ 2 件(うち合格 2)
3段目 [稼働] 予約が入っている … cron の予約 1 本
4段目 [装置のみ] 予約どおり最近動いている … cron.log 32.41 日前/最新の合否ログ 32.40 日前(どちらも 8 日以内なら稼働)
到達: 3 / 4 段階(4段目「予約どおり最近動いている」で途切れている)

読み方:稼働=跡がある/装置のみ=仕組みはあるが、動いた跡が無い・古い/未着手=仕組みも跡も無い。 この例は、予約は入っているのに1か月以上動いた跡が無い、という状態です。「予約したから大丈夫」の感覚だけでは、気づけません。

✓ ここまでの確認: measure.py が、4行の判定と「到達: ○ / 4 段階」の行を出した。 自分の仕組みが、何段目で途切れているかを読めた。

NEXT

前編のまとめと、後編へ

  • 「回っている」は段階に分けて数える
  • 測る材料は、記憶ではなく残った跡(テスト・ログ・予約・最後に動いた日)
  • 装置があるだけでは稼働と呼ばない

LAB 017

わざと止めて、読み方を決める

測定が本当に効いているかは、わざと止めて判定が下がるのを見るまで分かりません。そのあと、結果の読み方を決めます。 →