使用量を記録するだけのつもりで組んだ定期ジョブが、その使用量そのものを押し上げていました。
グラフが右肩上がりになっているのを見て、最初は他のジョブを疑いました。時刻を突き合わせると、増分はきれいに監視ジョブの起動時刻に並んでいます。読み取りのつもりで叩いていたコマンドが、ターンを開始していたわけです。
非対話実行では、この種の間違いが静かに積み上がります。手元で対話的に叩いているときは、ターンが始まれば画面が動くので気付きます。スケジュール実行にはその手掛かりがありません。
個人開発でスケジュール実行を増やしていくほど、この「気付けなさ」は効いてきます。今回はその境界を、記憶ではなく機械が守れる形に置き換えるまでの記録です。
ターンを開始するかどうかを、まず表に書き出す
print モード(-p)では、読み取り専用のスラッシュコマンドはターンを開始せずに答えます。一方で対話専用のコマンドは明示的に拒否されます。つまり、コマンドは実質的に三つに分かれます。
分類 非対話実行での挙動 監視ジョブから叩いてよいか
readonly ターンを開始せずに答える よい
turn ターンを開始する(モデル呼び出し・副作用あり) だめ
interactive 対話専用として拒否される そもそも成立しない
ここで大事なのは、この三分類を「知っている」状態で運用しないことです。私はこの分類を頭に入れていたつもりで間違えました。人の記憶は、コマンドが増えたときに追随しません。
表として外に出し、コードから参照できる形にします。
未分類を実行させない — fail-closed のゲート
分類表を持つだけでは足りません。表に載っていないコマンドが来たときにどうするかで、設計の性質が決まります。
私は fail-closed を採りました。未分類なら実行せず、非ゼロで落とします。新しいコマンドが増えたときにジョブが止まるのは煩わしいのですが、止まる方が黙って課金されるより扱いやすいという判断です。
#!/usr/bin/env python3
"""非対話実行のためのターン境界ゲート。
分類テーブルにないコマンドは実行せずに落とす(fail-closed)。"""
import re, shlex, subprocess, sys
READONLY = { "usage" , "status" , "model" , "help" , "cost" , "context" , "mcp" , "config" }
TURN = { "init" , "review" , "compact" , "run" , "agent" , "fix" }
INTERACTIVE_ONLY = { "settings" , "login" , "logout" , "clear" , "resume" , "quit" }
EXIT_UNCLASSIFIED = 78 # 分類不能: 実行しない
EXIT_WOULD_SPEND = 79 # ターンを消費する: 監視ジョブでは実行しない
EXIT_INTERACTIVE = 80 # 対話専用: 非対話実行では成立しない
_norm_re = re.compile( r ' ^ / ? ([ a-z ][ a-z0-9_- ] * ) ' )
def normalize (raw):
"""'/usage --json'・' /Usage'・'/mcp:list' などを正規名へ落とす。
先頭の1トークンだけを見る。分解できなければ None(=分類不能)。"""
if raw is None :
return None
s = raw.strip()
if not s:
return None
try :
head = shlex.split(s)[ 0 ]
except ValueError :
return None # クォート不整合はそのまま落とす
head = head.split( ':' , 1 )[ 0 ].lower() # /mcp:list -> mcp
m = _norm_re.match(head)
return m.group( 1 ) if m else None
def classify (raw):
name = normalize(raw)
if name is None :
return "unclassified" , None
if name in READONLY :
return "readonly" , name
if name in TURN :
return "turn" , name
if name in INTERACTIVE_ONLY :
return "interactive" , name
return "unclassified" , name
def run_readonly (raw, binary = "antigravity" , timeout = 60 ):
kind, name = classify(raw)
if kind == "turn" :
sys.stderr.write( f "[turn-gate] ' { name } ' はターンを消費します。実行しません \n " )
return EXIT_WOULD_SPEND , None
if kind == "interactive" :
sys.stderr.write( f "[turn-gate] ' { name } ' は対話専用です \n " )
return EXIT_INTERACTIVE , None
if kind == "unclassified" :
sys.stderr.write( f "[turn-gate] 未分類: { raw !r } 。表に追加するまで実行しません \n " )
return EXIT_UNCLASSIFIED , None
cmd = [binary, "-p" , f "/ { name } " , "--output-format" , "json" ]
try :
p = subprocess.run(cmd, capture_output = True , text = True , timeout = timeout)
except FileNotFoundError :
sys.stderr.write( f "[turn-gate] { binary } が見つかりません \n " )
return 127 , None
except subprocess.TimeoutExpired:
sys.stderr.write( f "[turn-gate] タイムアウト: { timeout } s \n " )
return 124 , None
return p.returncode, p.stdout
if __name__ == "__main__" :
code, out = run_readonly(sys.argv[ 1 ] if len (sys.argv) > 1 else "" )
if out:
sys.stdout.write(out)
sys.exit(code)
実装で意識した点を三つ挙げます。
終了コードを三つに分ける
78・79・80 を別々に割り当てているのは、後から集計するためです。
未分類(78)が出るのは、こちらの表が古いという意味です。ターン消費(79)が出るのは、ジョブの書き方が間違っているという意味です。対話専用(80)が出るのは、そもそも非対話に持ち込めない処理を持ち込んでいるという意味です。原因が違うものを同じコードで落とすと、ログを見ても次の一手が決まりません。
正規化は先頭1トークンだけに絞る
--output-format json のようなフラグや、/config get model のような引数まで解釈しようとすると、分類器がコマンドの仕様を追いかけ続けることになります。
ターン境界の判定に必要なのは先頭の名前だけです。それ以上を見に行かないことを、意識的な制約として置いています。
クォート不整合は分類不能として落とす
shlex.split は閉じていないクォートで ValueError を投げます。ここで例外を握り潰して「たぶん readonly だろう」と進めてしまうと、fail-closed の意味がなくなります。
壊れた入力は壊れたまま拒否する。これが実際にいちばん効いた判断でした。
静的な棚卸しだけでは足りなかった
ゲートを書く前に、私はもっと軽い方法を試しています。スクリプトを grep して、どのコマンドを叩いているかを一覧にすればいいのではないか、という発想です。
これがうまくいきませんでした。理由を数字にするため、起動行を抽出して「文字列として静的に読めるか」を分類するスキャナを書きました。
#!/usr/bin/env python3
"""シェルスクリプト中のコマンド起動を抽出し、静的スキャナが
「文字列リテラルとして読めるか」を分類する。"""
import re, sys, os, json
CMD_TOKEN = re.compile( r ' (?:^ | [ ;|& ] {1,2} \s *| \$\( \s *| ` \s * )\s * ([ A-Za-z_ ][\w ./- ] * )\s ' )
VAR_REF = re.compile( r ' \$\{ ? [ A-Za-z_ ][\w] * \} ? ' )
HEREDOC = re.compile( r '<<- ? \s * [ \' " ] ? ([ A-Za-z_ ][\w] * ) ' )
def classify_line (line):
"""literal / dynamic / None"""
s = line.strip()
if not s or s.startswith( '#' ):
return None
m = CMD_TOKEN .search( ' ' + s)
if not m:
return None
head = m.group( 1 )
parts = s.split()
if parts and VAR_REF .search(parts[ 0 ]):
return ( 'dynamic' , head) # コマンド名自体が変数展開
rest = s[m.end():]
if VAR_REF .search(rest) or '$(' in rest or '`' in rest:
return ( 'dynamic' , head) # 引数が実行時に決まる
return ( 'literal' , head)
def scan (path):
stats = { 'literal' : 0 , 'dynamic' : 0 , 'heredoc_blocks' : 0 , 'lines' : 0 }
heads = {}
try :
text = open (path, encoding = 'utf-8' , errors = 'replace' ).read()
except OSError :
return stats, heads
stats[ 'heredoc_blocks' ] = len ( HEREDOC .findall(text))
for line in text.splitlines():
stats[ 'lines' ] += 1
r = classify_line(line)
if not r:
continue
kind, head = r
stats[kind] += 1
heads.setdefault(head, { 'literal' : 0 , 'dynamic' : 0 })[kind] += 1
return stats, heads
def main (roots):
total = { 'literal' : 0 , 'dynamic' : 0 , 'heredoc_blocks' : 0 , 'lines' : 0 }
allheads, files = {}, 0
for root in roots:
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames
if d not in ( '.git' , 'node_modules' , '.next' )]
for fn in filenames:
if not fn.endswith(( '.sh' , '.bash' , '.md' , '.txt' )):
continue
p = os.path.join(dirpath, fn)
try :
if os.path.getsize(p) > 400_000 :
continue
except OSError :
continue
s, h = scan(p)
if s[ 'literal' ] + s[ 'dynamic' ] == 0 :
continue
files += 1
for k in total:
total[k] += s[k]
for head, c in h.items():
a = allheads.setdefault(head, { 'literal' : 0 , 'dynamic' : 0 })
a[ 'literal' ] += c[ 'literal' ]
a[ 'dynamic' ] += c[ 'dynamic' ]
print (json.dumps({ 'files' : files, 'total' : total}, ensure_ascii = False ))
tops = sorted (allheads.items(),
key =lambda kv: - (kv[ 1 ][ 'literal' ] + kv[ 1 ][ 'dynamic' ]))[: 12 ]
for head, c in tops:
n = c[ 'literal' ] + c[ 'dynamic' ]
print ( f " { head :14s } n= { n :5d } dynamic= { c[ 'dynamic' ] / n * 100 :5.1f } %" )
if __name__ == '__main__' :
main(sys.argv[ 1 :])
手元で運用しているスケジュール実行用のシェルスクリプト群に対して走らせた結果です。計測環境は Python 3.10.12 / Linux x86_64 です。
項目 値
対象ファイル数 20
抽出した起動行 1,430
静的に読める(literal) 599(41.9%)
実行時に組み立てる(dynamic) 831(58.1%)
ヒアドキュメントのブロック 25
コマンド別に見ると、偏りがはっきり出ました。
コマンド 件数 dynamic 比率
cat 207 100.0%
awk 60 100.0%
cd 46 91.3%
python3 80 85.0%
grep 58 79.3%
echo 216 53.7%
git 156 34.6%
ls 63 20.6%
半分以上の起動は、実際に渡る文字列が実行時にしか決まりません。パス、リポジトリ名、日付。どれも変数に入っています。
つまり、スクリプトを静的に読んで「どのコマンドを叩いているか」を棚卸ししても、半分以上は見えていないことになります。私が最初に grep で済ませようとして取りこぼしたのは、まさにこの部分でした。
静的な棚卸しは補助にはなります。ただし、境界を守る役目は実行時のゲートに持たせるしかありません。ここが設計の分岐点でした。
完全一致の表はどこで外れるのか
もう一つ確かめたかったのは、分類表を「素朴な完全一致」で持った場合にどれだけ外すのか、です。
/usage だけを表に入れておけば十分に思えます。しかし実際のスクリプトには /usage、/usage --output-format json、/mcp:list のような書きぶりが混ざります。
比較用のケース集合を29件用意しました。これは本番ログではなく、実運用のスクリプトに現れた書きぶりを手で集めて期待クラスを付けたものです。その前提で読んでください。
#!/usr/bin/env python3
"""素朴な完全一致テーブルと、正規化つき分類器の取りこぼしを比較する。"""
import time, statistics
from turn_gate import classify, READONLY , TURN , INTERACTIVE_ONLY
CASES = [
( "/usage" , "readonly" ), ( "usage" , "readonly" ), ( " /usage " , "readonly" ),
( "/usage --output-format json" , "readonly" ), ( "/Usage" , "readonly" ),
( "/mcp:list" , "readonly" ), ( "/mcp list" , "readonly" ), ( "/status" , "readonly" ),
( "/cost" , "readonly" ), ( "/context" , "readonly" ), ( "/config get model" , "readonly" ),
( "/model" , "readonly" ), ( "/help" , "readonly" ),
( "/init" , "turn" ), ( "init" , "turn" ), ( "/review --base main" , "turn" ),
( "/compact" , "turn" ), ( "/agent run x" , "turn" ), ( "/fix" , "turn" ),
( "/settings" , "interactive" ), ( "/login" , "interactive" ),
( "/resume" , "interactive" ), ( "/clear" , "interactive" ),
( "/telemetry" , "unclassified" ), ( "/doctor" , "unclassified" ),
( "" , "unclassified" ), ( " " , "unclassified" ),
( '/usage "unclosed' , "unclassified" ), ( "--output-format json" , "unclassified" ),
]
def naive (raw):
"""正規化なしの完全一致テーブル"""
if raw in { "/" + c for c in READONLY }:
return "readonly"
if raw in { "/" + c for c in TURN }:
return "turn"
if raw in { "/" + c for c in INTERACTIVE_ONLY }:
return "interactive"
return "unclassified"
def score (fn):
ok = sum ( 1 for raw, exp in CASES if fn(raw) == exp)
# 危険な誤り: ターン消費・対話専用を readonly と誤認する
unsafe = sum ( 1 for raw, exp in CASES
if exp in ( "turn" , "interactive" ) and fn(raw) == "readonly" )
# 過剰拒否: readonly を unclassified と誤認する(安全側だが運用は止まる)
overblock = sum ( 1 for raw, exp in CASES
if exp == "readonly" and fn(raw) == "unclassified" )
return ok, unsafe, overblock
N = len ( CASES )
for label, fn in (( "完全一致" , naive), ( "正規化" , lambda r: classify(r)[ 0 ])):
ok, unsafe, over = score(fn)
print ( f " { label :6s } : 正解 { ok } / { N } ( { ok / N * 100 :.1f } %) "
f "危険な誤分類 { unsafe } 過剰拒否 { over } " )
t = []
for _ in range ( 5 ):
s = time.perf_counter()
for _ in range ( 20000 ):
for raw, _e in CASES :
classify(raw)
t.append((time.perf_counter() - s) / ( 20000 * N) * 1e6 )
print ( f "1件あたりの分類コスト: { statistics.median(t) :.3f } us (中央値, 5回)" )
結果です。
方式 正解 危険な誤分類 過剰拒否
完全一致テーブル 19/29(65.5%) 0 7
正規化つき分類器 29/29(100.0%) 0 0
分類そのものの所要時間は中央値 15.0 マイクロ秒でした。
予想と逆だったこと
私が事前に想定していたのは、素朴な表を使うと危険な誤分類が出る、という筋書きでした。/init --dry-run のような書き方が /init と一致せずに readonly 側へ落ちる、といった事故です。
実際に測ると、危険な誤分類は 0 件でした。完全一致表が外した10件は、すべて「本来 readonly なのに未分類として弾いた」側に寄っています。うち7件が過剰拒否です。
理由は考えてみれば単純でした。完全一致から外れた入力は、必ず未分類のバケツに落ちます。fail-closed である限り、未分類は実行されません。表の粗さは、安全性ではなく可用性を削るのです。
この非対称性は、運用の順番を変えました。
分類表を完璧に作ってから導入するのではなく、粗い表のまま fail-closed で先に入れてよい。そのうえで、78 番で落ちたログを見ながら表を育てていけばよい。私はこの順序を推奨します。逆に fail-open を選んだ場合、同じ粗い表がそのまま課金と副作用に直結します。表の完成度に対する要求水準が、設計ひとつで大きく変わるということです。
落とし穴も一つ書いておきます。この非対称性は fail-closed だから成り立つもので、「未分類は許可」という一行を入れた瞬間に反転します。ゲートを緩めたくなったときに、まずここを思い出していただければと思います。
拒否のコストは無視してよい
全呼び出しにゲートを挟むと遅くなるのではないか、という懸念は当然あります。測りました。
経路 所要時間(中央値)
拒否(プロセスを起こさない) 18.4 マイクロ秒(n=2000)
参考: 子プロセスを1回起動 1,054 マイクロ秒(n=60)
拒否経路は、子プロセスを一度起こすコストの 57 分の 1 でした。約 1.7% です。
言い換えると、ゲートは「本来起動していたはずのプロセスを起こさない」ことで、自分のコストを何十倍も回収します。判断に迷う水準ではありませんでした。
運用に載せる順序
最後に、実際に入れるときの順番をまとめます。
三分類の表を書く。この時点で網羅は狙わない
fail-closed のゲートを、非対話実行の入口に挟む。終了コードは原因ごとに分ける
一週間ほど 78 番の発生を集め、正当なものを表へ追加する
79 番が出た箇所は表ではなくジョブの書き方を直す
3 と 4 を混同しないことが要点です。78 番は表の不足、79 番はジョブの誤りで、直す場所が違います。
次に手を動かすとしたら、非対話実行から叩いているコマンドを一つ選び、それが本当にターンを開始しないのかを確かめるところからだと思います。私の場合、そこで一つ見つかりました。
なお仕様や挙動はバージョンによって変わりますので、分類表は一次情報で確認したうえで作成してください。私自身、この表は運用しながら育てている途中です。同じ落とし穴を踏まずに済む方がいれば嬉しく思います。