AIエージェントことはじめ — LAB 016
— 段階に分けて、残った跡を数える —
夜に AI が回り始めると、次に困るのは「本当に回っているのか」が分からないことです。 止まっても知らせは来ませんし、「回っているはず」という感覚は当てになりません。 この記事では、テストの有無・実行ログ(動いた記録)・予約(決めた時刻に自動で動かす設定)・最後に動いた日といった残った跡から、 いま何段目まで届いているかを数える小さな測定スクリプトを、手元で試しながら作ります。 前編では、「回っている」を段階に分け、測る材料を決めて、測定スクリプトを走らせるところまで進めます。
約35分
全部やる場合の目安
3 STEP
手順の数
4 段階
このスクリプトが測る段数
INTRO
LAB 014 で、夜に AI を働かせる仕組みができました。 ただ、仕組みを作ったことと、それが回り続けていることは別の話です。 LAB 014 で見たとおり、AI の操作が許可されず止められても、エラーにならず、正常終了に見えます。 止まっていても、画面は静かなままです。
「回っているかどうか」を感覚で判断すると、次の3つの罠にはまります。
静かなのは「順調」なのか「止まっている」のか、画面からは区別が付かない。→ 画面ではなく、残った跡を見る
予約を入れた、スクリプトを置いた。それは「動かせる」であって「動いた」ではない。→ 実際に動いた跡があるときだけ「稼働」と呼ぶ
土台のテストが無いのに、夜の実行だけは回っている、など。→ 1段目から順に見て、途切れた所で止めて数える
筆者は 2026-08-16 に、この考え方で「ループ計測」という自作の画面(AI の作業が、人の手を借りずに繰り返し回っている度合いを測る画面)を作りました。 一度きりの診断書にすると翌日には古くなるので、開くたびにコードとログを数え直す形にしています。 この記事は、その考え方をいちばん小さい形に縮めたものです。
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段目から順に見て、最初に途切れた所で数えるのをやめます。6段目だけ動いていても「6段階まで来た」とは言いません。ループは途中が切れていると回らないからです。
状態は3つ。稼働(実際に動いた跡がある)/装置のみ(仕組みはあるが使われていない)/未着手。筆者の画面では、自動実行が ON でも最後の実行が3日より前なら稼働と呼びません。
この記事で作る測定スクリプトは、LAB 014・015 で作ったものに合わせて、上の6段階を4段階に縮めます (STEP 2 で対応を示します)。段階の数は、自分の仕組みに合わせて決めてかまいません。
まず、いま自分の仕組みが何段目まであるのかを、AI に整理させます。何も変更させず、読むだけです。
次の6段階について、私のフォルダ(~/tools と ~/night)にそれぞれ何があるか、
ファイル名を挙げて整理して。無いものは「無い」と書いて。
1 人が読んで判定する
2 機械が合否を判定できる(_selftest.py)
3 合否が1つのコマンドに束ねてある
4 落ちたら自動でやり直す(完了条件)
5 自分で起動して回り続ける(cron の予約)
6 別の役が検査する(検査役)
ファイルの作成や設定の変更は、しないで。
✓ ここまでの確認: 6段階の意味を言い換えられる。「連続して届いた所まで」「装置だけでは稼働にしない」の2つのルールを、自分の言葉で説明できる。
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日以内に更新されている | 予約はあるが、更新が古い |
cron.log と、最新の合否ログ(run-*.log)の両方が新しいことを見ます。LAB 014 の予約は出力を cron.log に書くので、予約が一度も動いていなければ cron.log は更新されません。ただし cron.log は予約が失敗したときも更新されます(command not found などが書かれる)。そこで「最新の合否ログも新しい」を足しました。ただし合否ログを予約が書いたのか、人が手で走らせたのかは区別していません。予約が失敗し続けていても、人が run.py を手で1回走らせると、4段目が稼働に見えることがあります。「予約が成功した」ことの完全な証明ではありません。迷ったら cron.log の中身を自分の目で読んでください
測る前に、材料が実際にあるかを目で確かめます。道具のフォルダが ~/tools でなければ、STEP 3 のスクリプト先頭の TOOLS を、自分の場所に直します。
ls ~/tools
ls ~/night ~/night/log
crontab -l
ls ~/tools:道具のフォルダの中身を並べます。道具ごとにフォルダが見えれば正常ですls ~/night ~/night/log:夜の仕組み(run.py など)と、ログのファイルを並べますcrontab -l:いま入っている予約の一覧です。何も入っていないと「no crontab for …」と出ますが、故障ではなく、3段目が「未着手」と出るだけですどの跡も無い段は、それで正しい(まだ作っていない)です。無いまま測っても、「未着手」と出るだけで、壊れるものはありません。
✓ ここまでの確認: 4つの段それぞれについて、どのファイル・ログ・予約を見るかを言える。 上の3つのコマンドを実行して、何が出て何が出ないかを確かめた。
STEP 3 — 目安 15分 / この記事の中心
STEP 2 の4つの跡を数えて、段ごとの判定と、到達段数を表示する小さなスクリプトです。 やることは跡を読むだけで、何も書き換えません。
ターミナルに貼ってください。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
~/night/measure.py というファイルに書き込まれるだけです。「No such file or directory」と出たら ~/night フォルダが無いので、つまずき集を見てくださいmain() の2つ目のループが「連続して届いた所まで」の数え方です。最初に「稼働」でない段が出た所で、そこまでの段数を表示して止まりますRESULT の正規表現は、LAB 015の run.py がログの最後に書く「結果: 合格」「結果: 不合格 …」の行を、行頭だけで探します。AI の返事の途中(行の途中)に「結果: 合格」という文字が混ざっていても数えません(行頭に来る形までは防げません)。ただし2段目は「合否が出たか」を見るだけで、不合格のログばかりでも稼働になります(合格の数は表示のみ。「良い出来」は測れません)LOOP_TOOLS などは、環境変数(コマンドに添える一時的な設定値)で差し替えられるようにしてあります。後編の STEP 4 で、本物のフォルダに触らず判定の変化を試すために使いますcrontab が動かないことがあるので、人が自分のターミナルで走らせてください(LAB 015の STEP 5 と同じ)/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
わざと止めて、読み方を決める
測定が本当に効いているかは、わざと止めて判定が下がるのを見るまで分かりません。そのあと、結果の読み方を決めます。 →