ANTIGRAVITY LABEN
記事一覧/Antigravity 基本
Antigravity 基本/2026-08-10上級

非対話実行でターン境界を守る — コマンド分類テーブルと fail-closed ゲートの実装メモ

使用量を記録するはずの定期ジョブが、実行のたびにモデルを呼んでいました。ターンを開始するコマンドとしないコマンドを分類表にして、未分類を実行させないゲートを実装し、実運用のスクリプトを走査して測った記録です。

Antigravity350CLI5自動化35運用設計24スケジュール実行10

プレミアム記事

使用量を記録するだけのつもりで組んだ定期ジョブが、その使用量そのものを押し上げていました。

グラフが右肩上がりになっているのを見て、最初は他のジョブを疑いました。時刻を突き合わせると、増分はきれいに監視ジョブの起動時刻に並んでいます。読み取りのつもりで叩いていたコマンドが、ターンを開始していたわけです。

非対話実行では、この種の間違いが静かに積み上がります。手元で対話的に叩いているときは、ターンが始まれば画面が動くので気付きます。スケジュール実行にはその手掛かりがありません。

個人開発でスケジュール実行を増やしていくほど、この「気付けなさ」は効いてきます。今回はその境界を、記憶ではなく機械が守れる形に置き換えるまでの記録です。

ターンを開始するかどうかを、まず表に書き出す

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 の意味がなくなります。

壊れた入力は壊れたまま拒否する。これが実際にいちばん効いた判断でした。

ここまでお読みいただきありがとうございます。

この記事の続きを読む

この先には、実装コードやベンチマーク結果など、実務でお役に立てる内容をご用意しています。このサイトは広告を掲載しておらず、サーバーや開発にかかる費用はメンバーの皆様のご支援で成り立っています。もしお役に立てていましたら、ご支援いただけますと大変ありがたいです。

この記事で得られること
ターンを開始するコマンドと開始しないコマンドを三分類し、未分類を実行させない fail-closed ゲートを実装コードごと示します
実運用のシェルスクリプト20ファイル1,430件の起動を走査したところ58.1%が実行時に文字列を組み立てており、静的な棚卸しだけでは届かない理由が数字で出ました
素朴な完全一致表と正規化つき分類器を29ケースで比較すると、誤りは危険な誤分類ではなく過剰拒否に偏るという、事前の予想と逆の性質が見えました
Stripe による安全な決済 · いつでもキャンセル可能

この記事を購入する

この先の内容をすべてお読みいただけます。一度のご購入で、いつでも何度でもアクセスできます。このサイトは広告を掲載しておらず、皆さまのご支援がサーバー費用などの運営を支えています。

または
メンバーシップなら全記事が読み放題 →
シェア

お読みいただきありがとうございます

Antigravity Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

関連記事

Antigravity 基本2026-06-24
Antigravity 2.0・CLI・IDE・SDK — 4つのサーフェスを実プロジェクトで束ねる
Antigravity 2.0(デスクトップ)・CLI・IDE・SDK の4つの入り口を、選び方の基準だけでなく、1つのプロジェクトの中で実際にどう束ねて回すかまで掘り下げます。ヘッドレス実行を自動化に組み込むコード例と、コストと移行の落とし穴も添えました。
Antigravity 基本2026-06-29
クォータが尽きかけたエージェントを止めずに走らせる — 段階的劣化の設計
月のクォータが残り少ないとき、エージェントを全停止する以外の道があります。能力を一段ずつ落としながら価値ある成果を出し続ける「段階的劣化」を、ポリシーのコード付きで設計します。
Antigravity 基本2026-06-24
Antigravity 4サーフェスを1案件で組み合わせる — SDKで自作エージェントを動かすまで
Antigravity 2.0・CLI・IDE・SDK を1つの案件の中でどう使い分け、どう橋渡しするか。設計の発散から本番の収束、そして Python SDK で小さなカスタムエージェントを動かすところまでを、実際の運用フローに沿って実装込みで解説します。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →