前提
個人開発で、複数のAIエージェントセッション(以下「セッション」)を並行で走らせて、それぞれに別々の作業をさせる自動化基盤を運用しています。各セッションは作業が終わると「完了報告」をテキストファイルへ書き出す設計でした。
セッションはそれぞれ別々の報告先ファイルを持つ想定だったのですが、実際には別セッション宛てのはずの報告が、こちらの報告ファイルを丸ごと上書きしてしまうという事故が繰り返し起きました。数千行あった過去の報告履歴が、他セッションの短い報告1件に置き換わってしまう、というような形です。
この記事では、固有の内部システム名やパス・IDは出さずに、問題の切り分け方と、実際に採用した防止設計を一般化して書きます。テーマ上、実装パターンとテスト設計に重点を置きます(設計判断の背景や生存確認まわりの話は別記事にまとめています)。
最初の原因調査が見落としていたこと
最初にこの問題を調べたときの結論は、「コード内の1箇所、レポートの生成テンプレートに"上書き保存してください"という指示文が埋め込まれている。ここを"追記してください"に直せば直る」というものでした。
もっともらしい結論だったのですが、これを鵜呑みにせず独立に一次情報(実際のコード・DB記録・生成された指示文そのもの)から再検証したところ、同じ「固定の1ファイルへ、全文を置き換える形で保存する」という意味の指示を出している箇所が、その1箇所以外に少なくとも2〜3系統あることが分かりました。
- エージェントへ動的に生成して渡す指示文(最初に見つかった箇所)
- 全セッション共通で読み込まれる、プロジェクト全体のルール文書
- 全セッション共通で読み込まれる、永続的なメモ書き(「必ずこのパスへ、今回の画面表示と一字一句同じ内容を保存すること」という趣旨の記述)
つまり**「コード上ただ1箇所を直せば直る」という前提そのものが誤り**でした。1箇所だけ直しても、他の指示源が同じ意味の指示を出し続けるので、事故は形を変えて再発します。
ここから得た教訓は、「原因が1つ見つかった」ことと「原因がそれで全部」ということは別物で、指示や設定が複数の独立した場所(コード・設定・恒久ルール文書・メモ)から同じ結論へ収束している場合は、それぞれを個別に潰さないと直らない、ということでした。
採用した設計: エージェント自身には報告を書かせない
これを踏まえて最終的に採用した設計方針は、根本的に発想を変えるものです。
エージェント(LLM)自身には、報告ファイルへ一切書き込みをさせない。
理由は単純で、エージェントに「正しく追記してください」と指示文で頼る限り、指示文の経路が増えるたびに同じ事故が再発するリスクを抱え続けるからです。指示ベースの制御は原理的に100%守られる保証がありません。
代わりに、次の3段構えにしました。
- エージェントの成果(実施内容の要約)は、構造化データとしてhost側のプロセスが受け取る(エージェント自身がファイルへ触れる経路を持たない)。
- host側のプロセスが、あらかじめ決まった許可リスト(allowlist)の中の1つの宛先へ、追記(append)専用の書き込みを行う。
- 書き込みの前後で機械的に検証し、想定外の縮小(=上書きされた疑い)を検知したら、書き込みを実装の失敗とは別カテゴリとして扱う。
実装パターン1: 宛先はタスク作成時に決めて永続化する
最初に検討したのは「レポート生成時に宛先を都度決める」案でしたが、これは失敗します。理由は、報告を書き出すタイミングでは、「そもそも誰がこのタスクを依頼したか」という情報がもう失われていることが多いからです(タスクを自動選択して実行する仕組みだと特に顕著です)。
なので、タスクを登録する時点で宛先を決めて、タスクのレコードに永続化する設計にしました。疑似コードで書くとこうです。
// タスク登録時: 宛先を明示的に受け取り、DBへ永続化する
function registerTask({ title, allowedReportTargets, reportTarget, ...rest }) {
const resolvedTarget = resolveReportTarget(reportTarget, DEFAULT_REPORT_TARGET);
if (!isAllowedReportTarget(resolvedTarget)) {
throw new Error(`REPORT_TARGET_NOT_ALLOWED: ${resolvedTarget}`);
}
return db.insertTask({ title, reportTarget: resolvedTarget, ...rest });
}
// resolveReportTarget: 未指定(NULL)は既定値にフォールバックする
// (過去に登録された、この列が存在しない古いタスクとの互換性のため)
function resolveReportTarget(explicitTarget, defaultTarget) {
return explicitTarget || defaultTarget;
}
ここでのポイントは、既存の古いタスク(この列自体がまだ無かった時代に登録されたもの)との互換性です。reportTargetが未設定(NULL)の場合は、既定の宛先へフォールバックさせることで、スキーマ変更後も過去のタスクが壊れないようにしています。
実装パターン2: allowlistで宛先を厳格に絞る
宛先は自由な文字列を受け付けず、あらかじめ決まった固定パスの集合(allowlist)との完全一致だけを許可します。相対パス・別ドライブ・UNCパス・パストラバーサル(../)・シンボリックリンク経由での迂回は、すべて拒否します。
const ALLOWED_REPORT_TARGETS = Object.freeze([
'/reports/session-1.txt',
'/reports/session-2.txt',
'/reports/session-3.txt',
// ...固定パスをすべて列挙する。動的生成はしない。
]);
function isAllowedReportTarget(path) {
// basenameでの部分一致や正規表現ではなく、完全一致のみ許可する。
// path.normalize等で正規化した後の値をallowlistと比較し、
// 相対パス・親ディレクトリ参照・別ドライブ・UNCパスはこの時点で
// 正規化結果がallowlistのいずれとも一致せず、構造的に弾かれる。
const normalized = normalizeAbsolutePathStrict(path);
return ALLOWED_REPORT_TARGETS.includes(normalized);
}
「拒否リスト(blocklist)ではなく許可リスト(allowlist)にする」のがここでの重要な判断です。危険なパターンを1つずつ拒否リストへ追加していく方式は、新しい迂回方法が見つかるたびに後追いで穴を塞ぐことになりがちです。固定パスの完全一致だけを許可する方式なら、想定外のパターンは全て自動的に弾かれます。
実装パターン3: 追記はhost側でatomicに行い、縮小を検知する
追記自体もエージェント任せにはしません。host側の専用処理が、次の手順で行います。
function appendReportSafely(targetPath, newSection) {
const before = fs.statSync(targetPath, { throwIfNoEntry: false });
const beforeSize = before ? before.size : 0;
// OSのファイルappendは、単一のwrite呼び出しであれば
// 小さいペイロードに対してはatomicに近い扱いになる。
// 「全文を読んで書き戻す」方式は使わない(後述)。
fs.appendFileSync(targetPath, newSection, { encoding: 'utf8' });
const after = fs.statSync(targetPath);
if (after.size < beforeSize) {
// 追記したはずなのにファイルが小さくなっている
// = 何らかの理由で上書きが起きた疑いがある。
return { ok: false, reason: 'UNEXPECTED_SHRINK', beforeSize, afterSize: after.size };
}
return { ok: true, beforeSize, afterSize: after.size };
}
ここで重要なのが、「既存の全文を読み込んでから、末尾に足して書き戻す」方式を避けている点です。この方式には次のような問題があります。
- 読み込みから書き戻しまでの間に、別プロセスが同じファイルへ書き込むと、後から書き戻した側が相手の変更を消してしまう(lost update)。
- ファイルが大きくなるほど、読み込み・書き戻しのコストが線形に増える。
- 改行コード(CRLF/LF)や文字コード(UTF-8 BOMの有無)を、読み込み時と書き戻し時で変換してしまうと、意図せず全体の見た目が変わる。
OSのappend専用API(O_APPEND相当のフラグでファイルを開く)を使えば、少なくとも「今ある内容の末尾に追加する」という操作自体は、全文を読み書きする方式より事故の起きる余地が小さくなります。
ただし、これだけでは**「たまたま同じサイズのデータで全置換された場合」**は検知できません。追記後にサイズが増えていれば良し、ではなく、追記前の内容の先頭部分(prefix)が変わっていないかもあわせて確認する必要があります。
function verifyPrefixUnchanged(targetPath, expectedPrefixHash) {
const currentPrefix = fs.readFileSync(targetPath, { encoding: 'utf8' }).slice(0, PREFIX_CHECK_LENGTH);
const currentHash = sha256(currentPrefix);
return currentHash === expectedPrefixHash;
}
サイズの増加チェックと、先頭部分のハッシュ一致チェックを両方満たして初めて「正しく追記できた」と判断します。
実装パターン4: 「報告の保存に失敗した」を実装失敗と混同しない
もう1つ重要な設計判断は、「実装(本来のタスク)は成功したが、報告の保存だけ失敗した」というケースを、実装そのものの失敗として扱わないことです。
これを混同すると何が起きるかというと、報告保存の失敗をきっかけに「このタスクは失敗した」と判定され、同じ内容のタスクが再度ディスパッチされて、実装がもう一度(無駄に)やり直されるという二次被害が起きます。実装自体は正しく終わっているのに、です。
async function completeTask(task, implementationResult) {
// 実装の成否と、報告保存の成否を、別々のフィールドで記録する
const implementationStatus = implementationResult.ok ? 'completed' : 'failed';
if (implementationResult.ok) {
const reportResult = appendReportSafely(task.reportTarget, implementationResult.summary);
if (!reportResult.ok) {
// 実装は成功のまま。報告保存の失敗だけを別カテゴリで記録する。
recordEvent(task.id, {
eventType: 'REPORT_APPEND_FAILED',
detail: { reason: reportResult.reason } // 本文全体や秘密情報は含めない
});
// ここで status を failed に落とさない。
}
}
db.updateTaskStatus(task.id, implementationStatus);
}
failure_categoryのような分類は「実装が失敗した」と「報告の保存だけ失敗した」を明確に区別できる形にし、機械可読な証跡(reasonコードなど)は残しつつ、本文全体や機微な情報はそこに含めないようにしています。
テスト設計
この手の「複数の書き手が同じリソースを取り合う」問題は、テストを書かないと簡単に再発します。実際にカバーしたケースの分類です(具体的なファイル・関数名は割愛します)。
| テストカテゴリ | 確認すること |
|---|---|
| 宛先マッピング | 各セッションIDに対応する宛先が、意図どおりに1つずつ決まる |
| 旧タスク互換 |
reportTargetが未設定(NULL)の古いタスクでも、既定値にフォールバックして動く |
| 不正な宛先の拒否 | allowlistに無いパス(相対パス・別ドライブ・../を含むもの等)は登録時点で拒否される |
| 追記で既存内容が保持される | 追記後、追記前の内容が先頭から完全に残っている(prefix一致) |
| 日本語・改行の保持 | マルチバイト文字や改行コードが追記の前後で壊れない |
| 同時書き込みでのlost update防止 | 2つの書き手がほぼ同時に追記しても、両方の内容が失われずに残る |
| 途中クラッシュ時の耐性 | 追記処理の途中でプロセスが落ちても、既存の内容が破損・消失しない |
| 同サイズ置換の検知 | 追記のはずが実際には同じバイト数で全置換されていた場合、prefixハッシュの不一致で検知できる |
| 報告失敗と実装成功の分離 | 報告保存に失敗しても、実装の成功判定・完了状態には影響しない |
| リトライ時の二重追記防止 | 同じ内容の報告が、リトライによって重複して追記されない |
これらは単体では地味なテストですが、「複数の独立した書き手が、共有の状態を奪い合う」系のバグは再現性が低く、番号を振って個別にケース化しておかないと、直したはずが別の形でまた出てくる、というのが今回の一番の実感です。
現状のステータス
正直に書いておくと、この記事で説明した設計は現時点では実装・検証の途中段階で、まだ本番のすべてのセッションに適用済みではありません。「こう設計した」というところまでが今回の記事の範囲で、「これで完全に事故が無くなった」という運用実績の報告ではない、という点を明記しておきます。
まとめ
- 「原因はコード上のこの1箇所」という最初の結論を鵜呑みにせず、独立に一次情報から検証し直したら、同じ意味の指示源が他に複数あることが分かった。単一原因だと決めつけず、指示・設定・ルール文書など収束している経路を全部洗い出す必要がある。
- LLMエージェントに「正しく追記してください」と指示文で頼る設計は、指示の経路が増えるたびに壊れるリスクを抱える。エージェント自身には書かせず、host側で構造化データを受け取ってappendする設計にすると、この種の事故のクラス自体を防げる。
- 追記は「全文読み込み→書き戻し」ではなく、OSのappend専用APIを使い、サイズの増加+先頭内容のハッシュ一致の両方で検証する。
- 「本来の作業は成功したが、報告の保存だけ失敗した」場合を、作業そのものの失敗と混同しないことで、無駄な再実行(二重の作業)を防げる。
同じように複数のワーカー・エージェントが共有ファイルへ書き込む構成を持っている方の参考になれば幸いです。
運営: J-WORKS — AIスタッフだけで、副業や小さな事業づくりをどこまで自動化できるかを実際に試しているプロジェクトです。