ある朝、スケジュールで回しているエージェントの出力を確認したら、生成物の一部が途中で切れていました。ログ上は「失敗」と記録されていたのに、切れたファイルはそのままディレクトリに残っていて、後続のビルド工程がそれを正常な入力として拾ってしまっていたのです。原因はすぐに分かりました。実行がタイムアウトに達してプロセスが SIGTERM で殺された瞬間、エージェントは目的のファイルへの書き込みの途中だったのです。
私自身、複数のコンテンツ生成パイプラインを夜間のスケジュールに載せて、寝ている間にエージェントへ作業を渡しています。手元で対話的に動かしているときは、途中で止まっても自分の目で気づけます。けれど無人実行では「中途半端な状態が静かに残り、それを誰も見ないまま次の工程が進む」という、対話実行には存在しなかった失敗モードが現れます。Antigravity 2.0 の CLI を cron 的な無人実行に組み込むほど、この問題は避けて通れなくなります。
ここでは、その「書きかけのファイル」を根絶するための原子的書き込みと、それに付随する二つの落とし穴(固定名 temp の残骸混入・exit code では守れない検証)への対処を、実際に私が落ち着いた形でまとめます。
タイムアウトで殺されたエージェントが残すもの
無人実行のエージェントを時間制限つきで動かすと、制限に達した時点で実行基盤がプロセスへシグナルを送って止めます。問題は、その瞬間にエージェントが何をしていたかを制御できないことです。
典型的に残るのは次の3種類です。
| 残るもの | 起きること | 後続への影響 |
| 半分書けた出力ファイル | 書き込みの途中で SIGTERM を受け、先頭だけが書かれる | 後続が「正常な入力」として拾い、壊れた成果物が伝播する |
| 固定名の一時ファイル | 前回の失敗で消されずに残った temp が、今回そのまま読まれる | 今回の生成物に 前回の内容が混入する(しかも無音) |
| ロックファイル | 正常終了時に消す前提のロックが残る | 次回起動が「実行中」と誤判定してスキップされる |
いちばん厄介なのは2番目です。1番目(半分書けたファイル)は中身を見れば異常に気づけますが、固定名 temp の残骸は「もっともらしい正しさ」を装って混入するため、出力件数も整合性チェックも通ってしまい、内容を一行ずつ照らし合わせるまで気づけません。私はこの混入で一度、別の記事の段落が紛れ込んだ生成物を危うく公開しかけたことがあります。
なぜ exit code だけでは守れないのか
無人パイプラインの守りを exit code に寄せるのは自然な発想です。けれど、上の3つはどれも exit code では捉えきれません。
- 半分書けたファイル: プロセスは「失敗(非ゼロ)」で終わるのに、ファイルはすでに部分的に存在する。exit code を見て「失敗だから後続を止める」だけでは、残ったファイル自体は掃除されない。
- 固定名 temp の混入: 今回の生成は**成功(ゼロ)**で終わる。前回の残骸を読んでしまっただけなので、exit code は何も警告しない。
- 空ファイル: 書き込み先は開けたが中身を書く前に死ぬと、サイズ0のファイルが成功扱いで残ることがある。
つまり守りには二つの軸が要ります。ひとつは「書き込みを途中状態で外に見せない」原子性。もうひとつは「書けたものが本当に意図した内容か」を確かめる、exit code とは独立した内容アサーションです。
書き込みを原子的にする — temp と rename
ファイルシステムにおける最も確実な原子的書き込みは、昔ながらの「別名の一時ファイルに全部書き切ってから、目的のパスへ rename する」パターンです。同一ファイルシステム上の rename(mv)はアトミックなので、目的のパスには「完全に書けた状態」か「まだ存在しない状態」のどちらかしか観測されません。書き込みの途中状態が外から見えることはありません。
bash で無人実行に組み込むなら、こうなります。
#!/usr/bin/env bash
set -euo pipefail
# 出力先と、同じディレクトリ内のユニークな一時ファイル
DEST="out/articles/result.json"
RUN_ID="$(date +%s)-$$" # 秒 + PID で実行ごとに一意
TMP="$(dirname "$DEST")/.tmp.${RUN_ID}.json"
# 途中で死んでも temp を必ず片付ける
cleanup() { rm -f "$TMP"; }
trap cleanup EXIT INT TERM
# エージェントの出力を temp に書き切る
agy run generate-article --out "$TMP"
# 書けた中身を確かめてから初めて原子的に公開する
[ -s "$TMP" ] || { echo "empty output"; exit 1; } # サイズ0を弾く
python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$TMP" # 壊れたJSONを弾く
mv -f "$TMP" "$DEST" # 同一FS上の rename はアトミック
trap - EXIT
echo "published: $DEST"
ここで効いているのは三点です。第一に、エージェントには temp に書かせること。目的のパスに直接書かせると、殺された瞬間にそのパスが壊れます。第二に、trap ... EXIT INT TERM でどんな止まり方をしても temp を消すこと。タイムアウトの SIGTERM もここで拾えます。第三に、mv の直前にサイズと中身の妥当性を検証すること。検証を通った場合だけ公開へ進みます。
Node で書く場合も考え方は同じで、fsync を挟んでから rename します。
import { writeFile, rename, open } from "node:fs/promises";
import { dirname, join } from "node:path";
async function atomicWrite(dest, data) {
const runId = `${Date.now()}-${process.pid}`;
const tmp = join(dirname(dest), `.tmp.${runId}`);
await writeFile(tmp, data, "utf8");
// OSバッファをディスクへ確定させてから rename する
const fh = await open(tmp, "r");
await fh.sync();
await fh.close();
await rename(tmp, dest); // 同一FS上ではアトミック
}
fsync(Node の fh.sync())を入れる理由は、rename 自体はアトミックでも、電源断などで「rename は済んだのにファイル本体のデータがまだディスクに届いていない」状態が理論上ありうるからです。無人で連続稼働させるなら、ここを省かないほうが安全だと考えています。
「前回の残骸」を踏まないための二つの規律
原子的書き込みは半分書けたファイルを根絶しますが、固定名 temp の混入は別の規律で防ぎます。私はこの問題で痛い目を見てから、二つを必ず守るようにしています。
1. 一時ファイルに固定名を使わない
/tmp/insert.txt のような固定名は、書き込みに失敗した回の残骸が次回そのまま読まれる温床です。上のコードのように 実行ごとに一意な名前(秒 + PID、あるいは対象の slug を含めた名前)を付ければ、前回の temp を今回が拾うことは構造的に起こりません。さらに temp は出力先と同じディレクトリに置きます。別ファイルシステム(例: /tmp と作業ディレクトリ)をまたぐと rename がアトミックでなくなり、コピー+削除に退化して原子性が崩れるためです。
2. 起動時に古い temp を掃除する
trap で消すのが基本ですが、プロセスが SIGKILL(kill -9)で強制終了されると trap すら走りません。そこで起動直後に、一定時間より古い自分の temp を掃除する保険を入れます。
# 起動時: 1時間以上前の取り残し temp を掃除(自分の命名規則のものだけ)
find out/articles -name '.tmp.*' -mmin +60 -delete 2>/dev/null || true
「自分の命名規則に一致するものだけ」を対象にするのが肝心です。広いパターンで消すと、同じディレクトリで並行している別エージェントの作業中ファイルを巻き込みかねません。
書き込み後の内容アサーションを exit code と分ける
今回固有の印を照合する
最後の守りが、exit code とは独立した内容検証です。mv で公開する前に「書けたものが今回意図した内容か」を確かめます。サイズ0や壊れた構文を弾くだけでなく、私は「今回の slug が本文に含まれているか」「前回の slug が紛れていないか」といった、その実行に固有の印を照合するようにしています。
# 公開直前: 今回の slug が含まれ、かつ前回の混入痕がないことを確認
grep -q "$SLUG" "$TMP" || { echo "self-marker missing"; exit 1; }
grep -q "$PREV_SLUG" "$TMP" 2>/dev/null && { echo "previous-run contamination"; exit 1; }
検証ゲートと公開を別の処理にする
ここで重要な運用上の判断がもう一つあります。検証と公開を一つの処理にまとめないことです。「検証して、通ったら同じ流れで mv する」と書くと、検証ステップの途中失敗が握り潰されて、未検証のまま公開へ進む事故が起こりえます。私は検証ゲートを通したことを確かめてから、別の処理として公開する、という分離を守っています。地味ですが、無人で何百回と回る前提では、この分離が効いてきます。
どこまで守るかの線引き
ここまでの守りは、対話実行ではほとんど不要です。自分が画面を見ているなら、半分書けたファイルにはすぐ気づき、手で消せます。原子的書き込みと内容アサーションが本当に効くのは、人が見ていない無人実行で、しかも出力が後続の自動工程の入力になるときです。Antigravity の CLI やスケジュールタスクで生成物を連鎖させるパイプラインは、まさにこの条件に当てはまります。
逆に、ワンショットで人が結果を確認してから次へ進むような使い方なら、ここまでの仕組みは過剰です。守りのコストは、その出力を誰も見ないまま次の工程が信じてしまうかどうかで決めるのが、私の基準です。個人開発でパイプラインを組むと、つい全部を堅牢にしたくなりますが、堅牢さを入れる場所を間違えると、ただ複雑になるだけだと感じています。
まず一本、いちばん下流で誰も中身を見ていない生成物を選び、その書き込みだけを temp + rename に置き換えてみてください。それだけで「朝、壊れた成果物に気づく」種類の事故は、かなり減るはずです。