金曜の夜に、素材をまとめる無人ジョブが「0 件」で正常終了しているのを見つけました。
エラーは出ておりません。例外も、警告もありません。ログには処理件数の 0 と、所要時間だけが残っていました。手で同じ手順を辿ってみて、ようやく分かりました。ジョブが見に行っていたフォルダは、私が数か月前に整理して中身を移したあとの、空の抜け殻だったのです。
置き場所を変えたことは覚えていました。覚えていなかったのは、その置き場所を永続メモリにも書いていたことのほうでした。メモリのファイル自体は数週間前に触られていて、日付だけ見ればそれほど古くありません。けれど中に書かれていた一行は、半年前の配置を指したままでした。ファイルの日付を鮮度だと思い込んでいたのだと気づいたのは、手で辿り直したあとでした。——更新日が表していたのは記憶の鮮度ではなく、ファイルの鮮度だったのです。
記憶は主張であって、事実ではありません。渡す前に、いま成り立つかを一度だけ確かめます。 個人開発で無人のスケジュール実行を増やしてから、私はこの一行を運用の前提に置くようになりました。
更新日が新しい記憶ほど、古い事実を持っていることがあります
最初のうち、私はメモリに何を書くかばかりを気にしておりました。書きすぎないこと、機密を混ぜないこと、重複させないこと。どれも必要ですが、どれも「書く瞬間」の話でした。事故は書く瞬間ではなく、半年後に読まれる瞬間に起きました。
厄介なのは、更新日が当てにならないところです。メモリを更新する動機は、たいてい「新しいことを書き足したいとき」に生まれます。すでに書いてある古い一行は、書き足すついでに読み返されることがありません。むしろ活発に育っているメモリほど、その内側に一度も検算されていない古い主張を抱え込みます。
人間が読めば「あれ、そこはもう移したはずでは」と手が止まります。無人実行は止まりません。メモリに書かれた前提を疑う理由を持っていないからです。私が観測した限りでは、この種の空振りは失敗としてすら記録されません。前提が古いだけで、手順そのものは正しく完走してしまうのです。
無人ジョブが「成功」と言いながら成果を出していない状態については、「成功」と記録されたのに成果がゼロだった無音失敗の話 にも書き残しております。今回の件は、その原因がジョブの外側——渡した記憶の側——にあった例でした。
腐る事実と、腐らない事実を分けます
対策を考えはじめたとき、最初に浮かんだのは全部のメモリに有効期限を付けることでした。三か月で失効させて、都度読み直すという案です。素直な発想だと感じていました。
やってみて、これは筋が悪いと分かりました。失効するのは記憶のほうではなく、記憶が指している対象のほうだからです。三か月経っても正しい事実は正しいままですし、三日で嘘になる事実もあります。一律の期限は、腐っていないものを捨てて、腐ったものを残します。
期限という発想そのものが、腐り方の違いを均してしまうのかもしれません。代わりに、事実の種類で仕分けることにしました。私の手元では、おおむね次のように分かれます。
事実の種類 例 腐り方
場所を指す事実 素材の置き場所、正本のパス、出力先 整理・移動のたびに黙って腐ります
名前を指す事実 スクリプト名、タスク名、フラグ名 改名で腐ります。参照側は気づけません
外の世界の事実 モデルの世代、料金、提供終了の予定 自分が何もしなくても腐ります
手順の事実 実行順序、前提条件、依存関係 実装を直したときに一緒に腐ります
判断の事実 なぜその方式を選ばなかったか、線引き ほとんど腐りません
好みの事実 文体、命名の癖、避けたい表現 ほとんど腐りません
上の四つには共通点があります。どれも外部に対応物があり、その対応物の有無を機械で確かめられる という点です。下の二つには対応物がありません。判断の理由を検算するコマンドは書けません。
そして、この線はそのまま「検算を添えるべきか」の線になりました。腐る事実には検算を添えます。腐らない事実には添えず、そのまま渡します。
主張のとなりに、検算を書いておきます
やり方はごく単純で、メモリの frontmatter に verify ブロックを一つ足すだけです。本文には従来どおり事実を書き、そのとなりに「この事実が成り立っているなら、これが通るはずだ」という確認手段を置きます。
---
name : reference-data-path
description : 参照データの正本は _reference_data/ 配下です
type : reference
verify :
kind : readable # 読めて中身があるか
target : data/_reference_data/keywords.txt
---
参照データを読むときは _reference_data/ を見ます。
---
name : wallpaper-batch-order
description : 派生生成は分類より先に走らせます
type : project
verify :
kind : grep # 実装側にその順序が残っているか
target : scripts/batch.sh
pattern : "derive_before_classify"
---
順序を入れ替えると分類が空振りします。
---
name : why-manual-approval
description : 不可逆な操作は自分で押すという線引き
type : feedback
verify :
kind : none # 対応物を持たない事実です
---
記録は自動、確定は手動。
kind は四つだけにしました。readable(対象が読めて中身があるか)、grep(対象の中に印が残っているか)、command(終了コードが 0 か)、none(検算対象を持たない)です。増やしたくなる気持ちは何度も湧きましたが、増やすほど「検算を書くのが面倒だから書かない」方向に傾きます。書き手が三十秒で決められる程度に粗いことのほうが、運用では効きました。
なかでも grep は地味に効きます。手順の記憶は、実装を直した瞬間に嘘になります。実装側に印になる語を一つ残しておけば、その語が消えた日に記憶のほうも落ちてくれるのです。
検算ランナーを書きます
依存ライブラリは入れませんでした。無人実行の環境は自分の手元と同じとは限らず、pip install が通らない日があります。frontmatter も二階層までしか使わないと決めて、正規表現と分割だけで読みます。
#!/usr/bin/env python3
"""永続メモリの主張を、参照する直前に検算します。"""
from __future__ import annotations
import re
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
FM = re.compile( r " \A --- \n (. *? ) \n --- \n " , re.S)
def parse_frontmatter (text: str ) -> dict :
"""依存なしで 2 階層までの frontmatter を読む簡易パーサです。"""
m = FM .match(text)
if not m:
return {}
data: dict = {}
parent = None
for raw in m.group( 1 ).splitlines():
if not raw.strip() or raw.lstrip().startswith( "#" ):
continue
indent = len (raw) - len (raw.lstrip())
if ":" not in raw:
continue
key, _, value = raw.strip().partition( ":" )
value = value.strip().strip( '"' ).strip( "'" )
if indent == 0 :
if value == "" :
parent = key # ネストの親(verify: など)
data[key] = {}
else :
parent = None
data[key] = value
elif parent:
data[parent][key] = value
return data
@dataclass
class Result :
name: str
status: str # fresh / stale / unverifiable
reason: str
def verify (spec: dict , root: Path) -> tuple[ str , str ]:
kind = spec.get( "kind" , "none" )
if kind == "none" :
# 判断や線引きは対応物を持たないので検算しません
return "unverifiable" , "検算対象なし(判断・原則)"
if kind == "readable" :
p = root / spec[ "target" ]
if not p.exists():
return "stale" , f "存在しません: { spec[ 'target' ] } "
try :
head = p.read_bytes()[: 4096 ]
except OSError as e:
return "stale" , f "読めません: { e. __class__ . __name__ } "
# 改行 1 バイトだけのファイルは -s も先頭バイト検査も通ってしまいます
if not head.strip():
return "stale" , f "中身が空でした: { spec[ 'target' ] } "
return "fresh" , "読めて中身があります"
if kind == "grep" :
p = root / spec[ "target" ]
if not p.exists():
return "stale" , f "存在しません: { spec[ 'target' ] } "
pattern = spec.get( "pattern" , "" )
if re.search(pattern, p.read_text( errors = "replace" )):
return "fresh" , f "一致: / { pattern } /"
return "stale" , f "一致しません: / { pattern } /"
if kind == "command" :
proc = subprocess.run(
spec[ "target" ], shell = True , cwd = root,
capture_output = True , timeout = int (spec.get( "timeout" , 20 )),
)
if proc.returncode == 0 :
return "fresh" , "終了コード 0"
return "stale" , f "終了コード { proc.returncode } "
return "unverifiable" , f "未知の kind: { kind } "
def scan (memory_dir: Path, root: Path) -> list[Result]:
results: list[Result] = []
for path in sorted (memory_dir.glob( "*.md" )):
fm = parse_frontmatter(path.read_text( errors = "replace" ))
if not fm:
results.append(Result(path.stem, "unverifiable" , "frontmatter がありません" ))
continue
spec = fm.get( "verify" )
if not isinstance (spec, dict ):
results.append(Result(fm.get( "name" , path.stem), "unverifiable" , "verify 未定義" ))
continue
status, reason = verify(spec, root)
results.append(Result(fm.get( "name" , path.stem), status, reason))
return results
def main () -> int :
memory_dir = Path(sys.argv[ 1 ] if len (sys.argv) > 1 else "memory" )
root = Path(sys.argv[ 2 ] if len (sys.argv) > 2 else "." )
results = scan(memory_dir, root)
mark = { "fresh" : "OK " , "stale" : "STALE" , "unverifiable" : "SKIP " }
for r in results:
print ( f " { mark[r.status] } { r.name } : { r.reason } " )
stale = [r for r in results if r.status == "stale" ]
print ( f " \n fresh= { sum ( 1 for r in results if r.status == 'fresh' ) } "
f "stale= { len (stale) } "
f "unverifiable= { sum ( 1 for r in results if r.status == 'unverifiable' ) } " )
return 1 if stale else 0
if __name__ == "__main__" :
raise SystemExit (main())
手元で走らせると、こうなります。実装側の印を消したあとの出力です。
$ python3 memcheck.py memory .
OK reference-data-path: 読めて中身があります
STALE wallpaper-batch-order: 一致しません: /derive_before_classify/
SKIP why-manual-approval: 検算対象なし(判断・原則)
fresh=1 stale=1 unverifiable=1
$ echo $?
1
印を実装に戻すと、同じ記憶がそのまま OK に戻ります。記憶を書き直す必要はありません。記憶と実装のあいだにずれがあるとき「だけ」赤くなる、という状態を作れたのが、この設計でいちばん嬉しかったところです。
存在するのに空、という状態を fresh にしません
ここが実装でいちばん時間を取られた箇所です。
最初に書いた readable は、Path.exists() が真なら通す実装でした。これでは足りません。同期フォルダの上で無人実行を回していると、ファイルはメタデータだけ先に見えていて、中身がまだ来ていない状態に出会います。存在しますし、サイズも本来の値を返してきます。それでも読み出しは空で返るのです。この挙動については、bash が「ファイルがありません」と言うのに Finder には見える理由 で別途書き残しました。
そこで先頭バイトを実際に読む実装に直したのですが、これでもまだ抜けます。
$ printf '\n' > data/_reference_data/keywords.txt # 改行だけのファイル
$ [ -s data/_reference_data/keywords.txt ] && echo "[ -s ] => true (サイズは 1 バイト)"
[ -s ] = > true ( サイズは 1 バイト )
[ -s ] は真を返します。先頭 1 バイトを読む検査も通ります。書き出し側が途中で落ちたときや、テンプレートだけ生成されて中身が入らなかったときに、ちょうどこの形が残ります。予想と逆でした——私は「空」を判定するのがいちばん簡単な部分だと思っていたのに、実際にはいちばん抜けやすい部分だったのです。
いまは先頭 4096 バイトを読んで strip() した結果が空でないことを条件にしています。空白と改行しかないファイルは stale として落ちます。
判定方法 存在しない 未実体化で空 改行だけ
exists()検出できます 見逃します 見逃します
[ -s ] 相当検出できます 検出できます 見逃します
先頭 1 バイト読み 検出できます 検出できます 見逃します
先頭 4096 バイト + strip 検出できます 検出できます 検出できます
入力そのものの鮮度を契約として扱う考え方は、無人パイプラインの入力フレッシュネス契約 にまとめてあります。今回の readable は、その契約をメモリ側から見た形だと思っていただければと思います。
本番の無人実行に置いたときの落とし穴
手元で通ったあと、無人実行に載せてから三つほど踏みました。どれも検算そのものより、検算を走らせる環境の側の問題でした。
一つ目は command の待ち時間です。ネットワーク越しの確認を検算に書いた記憶があり、接続が詰まった朝に検算だけで数分持っていかれました。いまは timeout を明示し、既定を 20 秒に固定しています。検算は本体の処理より軽くあるべきで、重い確認が要るなら、それは検算ではなく本体の前処理として書いたほうが素直だと感じています。
二つ目はシンボリックリンクです。readable は追跡した先を読むため、リンクだけ残って実体が消えた状態でも、環境によっては別のファイルに解決されて通ってしまいます。判定を厳しくしたい記憶では command にして test -f "$(readlink -f ...)" のような形で確かめる回避策を採っています。
三つ目が、いちばん間の悪いエラーでした。検算ランナー自身が例外で落ちると、無人ジョブは検算を通過したのか失敗したのか分からないまま先へ進みます。scan() の呼び出し全体を包んで、例外が出た場合は全件を stale として扱う——つまり安全側に倒す——のが、私の運用ではいちばん事故が少なくなりました。
try :
results = scan(memory_dir, root)
except Exception as e: # 検算基盤の障害は「全部信用しない」へ倒します
print ( f "verifier failed, holding all memories: { e } " , file = sys.stderr)
results = [Result(p.stem, "stale" , "verifier error" )
for p in sorted (memory_dir.glob( "*.md" ))]
判定に迷う場合はこの方向に倒すことをお勧めします。古い前提で走った日の後始末より、その朝に止まって手を入れるほうが、結局は短く済みました。
落ちた記憶は捨てず、保留として見せます
検算に落ちた記憶をどう扱うか。ここは少し迷いました。
黙って除外するのがいちばん簡単です。けれど、それをやると事故の形が変わるだけでした。古い前提で走る代わりに、前提がないまま走ることになります。どちらも無人実行は疑いません。
いまは、本文だけを渡さず、名前と落ちた理由を残しています。読み手(人でも、そのセッションの Claude でも)が「その話は前にあったが、いまは信用できない状態だ」と分かる形にしておきたかったのです。
#!/usr/bin/env python3
"""検算を通ったメモリだけを本文つきで渡し、落ちたものは見出しだけ残します。"""
from pathlib import Path
from memcheck import parse_frontmatter, scan
def build_context (memory_dir: Path, root: Path) -> str :
status = {r.name: r for r in scan(memory_dir, root)}
fresh_blocks, held_blocks = [], []
for path in sorted (memory_dir.glob( "*.md" )):
text = path.read_text( errors = "replace" )
fm = parse_frontmatter(text)
name = fm.get( "name" , path.stem)
body = text.split( "---" , 2 )[ - 1 ].strip()
r = status.get(name)
if r is None or r.status == "stale" :
# 黙って捨てません。「あったが今は信用できない」ことを見えるようにします
held_blocks.append( f "- { name } : 保留( { r.reason if r else '判定なし' } )" )
else :
fresh_blocks.append( f "## { name }\n{ body } " )
out = " \n\n " .join(fresh_blocks)
if held_blocks:
out += " \n\n ## 保留中の記憶(本文は渡していません) \n " + " \n " .join(held_blocks)
return out
if __name__ == "__main__" :
print (build_context(Path( "memory" ), Path( "." )))
実装側の印を消した状態で走らせた出力です。
## reference-data-path
参照データを読むときは _reference_data/ を見ます。
## why-manual-approval
記録は自動、確定は手動。
## 保留中の記憶(本文は渡していません)
- wallpaper-batch-order: 保留(一致しません: /derive_before_classify/)
保留の行が出た日は、私が直すべきものが二つのうちどちらかにあります。記憶が古いか、実装が意図せず変わったかです。どちらであっても、直す前に気づけていれば十分でした。
検算を持てない記憶は、そのまま渡します
kind: none を fresh と同じ扱いで渡している点は、意識してそうしました。
検算できないものを疑わしいものとして扱うと、いちばん腐りにくい記憶——なぜその方式を採らなかったか、どこで手を止めると決めたか——が真っ先に失われます。私にとって残す価値が高いのは、まさにその種類の記憶のほうです。腐りにくいから残す、という順番で考えています。
代わりに、検算を書けるはずの事実に kind: none を付けていないかは時々見ます。パスや名前を含む記憶に none が付いていたら、たいていは「検算を書くのが面倒だった日の私」の痕跡でした。
なお、メモリに何を書かないかという線引きは、鮮度とはまったく別の話です。渡す前にフォルダの中身を数える話は、ファイル名ではなく中身で鍵を数えた記録 に分けて書いてあります。鮮度の検算は、書いてよいと決めたものの上でだけ動かします。
運用に載せるときの順番
一度に全部やろうとすると挫けます。私は次の順で入れました。
既存メモリを読み返さずに、新規に書くものだけへ verify を義務にします。 過去分の棚卸しから始めると、たいてい途中で止まります。
検算ランナーを、無人ジョブの先頭で走らせます。 終了コードは無視して、出力だけログに落とします。この段階では止めません。
一週間ぶんのログを眺めます。 どの種類の事実がよく落ちるかが見えてきます。私の場合は、場所を指す事実が突出していました。
落ちたら止める、に切り替えます。 ここまで来てから、ローダー側の保留表示を入れます。
過去分は、落ちた記憶を直すついでに verify を足します。 棚卸しを目的にせず、事故の副産物として片づけていきます。
2 を飛ばして 4 から始めた日があり、朝から無人ジョブが軒並み止まって手が空かなくなりました。観測を先に、遮断を後に。 品質ゲートを増やすときはいつもこの順にしております。
明日いちばん先に検算を足すとしたら
自分のメモリを開いて、パスかファイル名が書かれている一行を探してみてください。おそらく一つは見つかります。そこに verify を三行足して、memcheck.py を一度だけ走らせてみてください。それだけで、半年後に空を掴む無人実行が一つ減ります。
私自身、いまも検算を書き忘れた記憶をときどき見つけます。それでも、記憶を疑う仕事を人間の注意力から機械に移せたぶんだけ、朝の確認が短くなりました。同じように無人実行を増やしている方の役に立てば嬉しく思います。読んでくださってありがとうございました。