最初に夜間自動化で痛い目を見たのは、エージェントが「親切」だったときでした。あるユーティリティ関数の重複を見つけて一本化してくれたのですが、片方は呼び出し側がエラーを握りつぶす前提で書かれていて、もう片方は例外を投げる設計でした。テストはすべて緑のまま通り、差分も読みやすく、コミットメッセージも丁寧。朝の私はそれを信用してマージし、その日の午後に本番のジョブが静かに止まりました。
無人実行の怖さは、エラーで止まることではありません。もっともらしく成功して見えること です。人間がレビューに入る前提なら、この種の取り違えは会話の中で気づけます。けれども夜間の Background Agent は、誰も見ていない時間に、誰にも止められないまま自信を持って間違えます。ここでは Antigravity の Background Agent を 30 晩ほど無人で回してきたなかで、被害を抑えるために効いた設計を運用視点で整理します。派手な自動化の話ではなく、「朝に後悔しないための地味な仕掛け」の記録です。
作業範囲より先に、被害範囲を決める
夜間タスクの設計では、つい「何をやらせるか」から考え始めてしまいます。順序が逆でした。先に決めるべきは 失敗したときにどこまで壊れうるか 、つまり被害範囲(blast radius)です。
個人開発で複数のリポジトリを一人で抱えていると、夜のうちに手が回らない手入れをエージェントに託したくなります。私が落ち着いた原則は単純で、エージェントには三重の囲いをかけます。
main には絶対に触れさせず、必ず使い捨ての作業ブランチで動かす。
書き換えてよいファイルを許可リストで明示し、それ以外への書き込みは禁止する。
1 回の実行で許す差分量に上限を設ける。
この三つは、エージェントの賢さとは無関係に効きます。本番運用に乗せる前提なら、賢く直してくれることよりも、取り違えを早く回避できることのほうを私自身は重視しています。
タスク定義そのものに、この囲いを言語化して埋め込みます。
# .antigravity/tasks/nightly-maintenance.md
## ゴール
許可リストのファイルについて、振る舞いを変えずに可読性と型安全性を改善する。
## 触れてよい場所(これ以外への書き込みは禁止)
- src/lib/* */* .ts
- src/utils/* */* .ts
## 絶対にやらないこと
- 公開 API のシグネチャ変更(引数・戻り値・例外の種類)
- main / develop への直接コミット
- 依存関係の追加・更新(package.json は読み取りのみ)
- 1 ファイルあたり 120 行を超える差分
## 完了の定義
- 既存テストが緑のまま
- 変更したファイルごとに、何を・なぜ変えたかを 1 行で要約
- 上記を満たせない場合は「変更なし」で終了し、理由を残す
ここで大事なのは「絶対にやらないこと」と「完了の定義」を、達成すべきタスクと同じ重みで書くことです。エージェントは目標に向かって最適化するので、制約を曖昧にすると、制約のほうを犠牲にして目標を達成しにきます。公開 API を勝手に変えられて困った経験から、シグネチャ変更の禁止は明示的に一行を割いて書くようにしました。
起動側でも囲いをかける(プロンプトを信用しすぎない)
タスク定義に制約を書いても、それはあくまで「お願い」です。無人で回す以上、機械的に強制できる囲いを起動スクリプト側にも持たせます。Antigravity CLI でセッションを起こし、作業ブランチを切ってから渡す形にしています。
#!/usr/bin/env bash
# scripts/nightly-agent.sh — cron から 1 タスク 1 起動で呼ぶ
set -euo pipefail
TASK_FILE = " $1 " # 例: .antigravity/tasks/nightly-maintenance.md
DATE = "$( date +%Y%m%d)"
BRANCH = "agent/nightly-${ DATE }-$( basename " $TASK_FILE " .md)"
# 使い捨て作業ブランチ。main は常にクリーンな出発点
git fetch origin main --quiet
git switch -c " $BRANCH " origin/main
# セッション起動。完了したら結果を JSON で受け取る
SESSION_ID = "$( antigravity sessions create \
--task " $TASK_FILE " \
--sandbox isolated \
--timeout 45m \
--max-output-tokens 64000 \
--format json | jq -r '.session_id')"
echo "started ${ SESSION_ID } on ${ BRANCH }"
# ポーリングは CLI に任せ、完了/タイムアウトで抜ける
antigravity sessions wait " $SESSION_ID " --timeout 50m || {
echo "session did not finish cleanly: ${ SESSION_ID }"
# 中途半端な変更は破棄してブランチごと捨てる
git switch main && git branch -D " $BRANCH "
exit 0
}
ポイントは二つあります。タイムアウトを必ず設けること。そして、きれいに終わらなかったセッションの成果物は疑わずに捨てる ことです。中途半端に処理されたコードを朝に拾い上げて検分する時間のほうが、再実行のコストより高くつきます。最初の頃は「途中まででも惜しい」と拾っていましたが、結局ほとんど使い物にならず、判断の負荷だけが残りました。
「テストが緑」を完了条件にしない
冒頭の失敗が示すとおり、テストの成否だけでは静かな劣化を捕まえられません。テストが薄い領域ほど、エージェントは自由に振る舞えてしまいます。そこで、マージ可否を判断する前に通す軽い検査層を一枚挟みます。狙いは「本当に振る舞いが変わっていないか」を、テスト以外の角度から確認することです。
私が実際に使っているのは、変更の形そのものを点検するゲートです。
#!/usr/bin/env python3
# scripts/diff_guard.py — 夜間ブランチの差分を機械的に検品する
import subprocess, sys, re
BASE = "origin/main"
MAX_FILE_LINES = 120 # タスク定義の上限と一致させる
PUBLIC_SIG = re.compile( r ' ^[ +- ]\s * export \s + ( async \s + ) ? function \s + \w + ' )
def changed_files (branch):
out = subprocess.run(
[ "git" , "diff" , "--numstat" , f " { BASE } ... { branch } " ],
capture_output = True , text = True , check = True ).stdout
return [l.split( " \t " ) for l in out.splitlines() if l.strip()]
def main (branch):
problems = []
for added, removed, path in changed_files(branch):
if added == "-" or removed == "-" : # バイナリ
problems.append( f "binary touched: { path } " )
continue
if int (added) > MAX_FILE_LINES :
problems.append( f " { path } : + { added } 行は上限 { MAX_FILE_LINES } 超" )
# 公開シグネチャに触れていないか
d = subprocess.run([ "git" , "diff" , f " { BASE } ... { branch } " , "--" , path],
capture_output = True , text = True ).stdout
sig_changes = [l for l in d.splitlines() if PUBLIC_SIG .match(l)]
# +/- が対で出るなら名前だけ変えた可能性。単独で出たら危険
if len (sig_changes) % 2 != 0 :
problems.append( f " { path } : 公開関数シグネチャの片側変更の疑い" )
if problems:
print ( "BLOCK:" )
for p in problems:
print ( " -" , p)
sys.exit( 1 )
print ( "diff_guard: ok" )
if __name__ == "__main__" :
main(sys.argv[ 1 ])
このゲートは賢いことは何もしていません。差分の量と、公開関数の増減が対になっているか、という二点を見るだけです。それでも、冒頭で私を刺した「片方だけ消えた関数」のような変更は、ここで止まります。意味理解に踏み込もうとすると検査自体が脆くなるので、機械的に判定できる形の異常だけを拾う と割り切ったほうが、無人運用では安定しました。
加えて、カバレッジの差分も見ています。変更後にカバレッジが下がっていたら、エージェントがテストの効いていない場所をいじった、あるいはテストを「通すために」緩めた可能性が高い。vitest run --coverage の出力を前夜の値と比べ、明確に低下していればそのブランチは保留にします。
再実行しても壊れない冪等なタスク定義
夜間ジョブは落ちます。サンドボックスのタイムアウト、ネットワークの一時障害、CLI の更新。落ちたら再実行したい。けれども素朴に組むと、再実行で同じ整形を二度かけたり、すでに分割した関数をさらに分割したりと、二重適用が起きます。
避け方は、タスクを「現在の状態を目標の状態に近づける」宣言として書くことです。「この関数を分割せよ」ではなく「この関数は単一責務であるべき。すでにそうなら何もしない」と書く。エージェントに現状を観測させてから動かすぶん、同じタスクを何度流しても収束します。起動スクリプト側でも、その日の同名ブランチが既にあれば新規実行をスキップする一行を足すだけで、cron の重複起動による事故が消えました。
# nightly-agent.sh の冒頭に追加
if git ls-remote --exit-code --heads origin " $BRANCH " > /dev/null 2>&1 ; then
echo "branch ${ BRANCH } already exists — skip"
exit 0
fi
朝のトリアージを数分で終わらせる
無人実行の価値は、朝の確認が軽いことで初めて回収できます。生成ブランチが毎晩三つ四つ届くなら、一つずつ丁寧に読む運用は続きません。私は「機械が下した一次判定」を先に見て、人間は判断が要るものだけに目を向ける形にしています。
#!/usr/bin/env bash
# scripts/morning-review.sh — 夜間ブランチを一覧で裁く
git fetch origin --quiet
for b in $( git branch -r | grep "origin/agent/nightly-$( date +%Y%m%d)" ); do
name = "${ b # origin / }"
files = $( git diff --name-only origin/main..." $b " | wc -l | tr -d ' ' )
if python3 scripts/diff_guard.py " $b " > /dev/null 2>&1 ; then
verdict = "REVIEW" # ゲート通過。人の最終判断へ
else
verdict = "REJECT" # ゲートで弾かれた。原則破棄
fi
printf "%-8s %-45s files=%s\n" " $verdict " " $name " " $files "
done
ここで REJECT のブランチは、よほどの理由がなければ開きません。読みに行くと「惜しい変更」に引っ張られて時間を溶かすからです。REVIEW のものだけを、差分の要約(タスク定義で 1 行ずつ書かせている)を頼りに見て、納得できればマージ、迷えば破棄します。迷ったら捨てる を既定にしておくと、夜間自動化は資産になり、判断疲れの源にはなりません。捨てた変更が本当に必要なら、翌晩また提案されます。
30 晩回して見えた現実
派手な成果は出ていません。毎晩の生成ブランチのうち、実際にマージするのはおおむね 55% で、半分強といったところです。残りは差分ゲートで弾かれるか、朝のトリアージで「振る舞いが変わっていないと確信できない」と判断して捨てています。それでも、コメントの補完や小さな関数分割、型注釈の追加といった「やった方がいいと分かっているのに後回しにしていた手入れ」が、放っておいても少しずつ進むのは確かな効用でした。
一番の変化は、コードベースへの安心感の置き方が変わったことかもしれません。以前は「エージェントが賢く直してくれること」に期待していました。今は「賢く間違えても被害が囲いの中に収まること」を設計しています。無人で動かすものに求めるべきは能力の上限ではなく、失敗したときの底の浅さなのだと、何度か朝に冷や汗をかいてようやく腑に落ちました。
夜間自動化を試すなら、最初の一晩は「触れてよいのは 1 ファイルだけ、差分は 50 行まで」という極端に狭い囲いから始めることを強く推奨します。狭すぎて物足りないくらいがちょうどよく、囲いを少しずつ広げていく過程で、自分のコードベースのどこをエージェントに任せられるかが自然と見えてきます。お読みいただきありがとうございました。