一覧に戻る

ブログ

会話履歴はサーバーに任せる:モバイル AI コーディングにおける対話復元の設計

Tactic Remote 1.8.2 がチャット履歴をモバイル側からサーバーへ移した理由、そしてその決断が順序の乱れと履歴消失という問題のクラス全体を根本から消し去った経緯。

公開日 タグ: engineering / architecture / ios / reliability / websocket
ブログ

このカテゴリのプロダクト/開発アップデートを確認できます。

カテゴリを見る

この記事は一つのアーキテクチャ上の決断について書く。Tactic Remote 1.8.2 がなぜチャットの会話履歴をモバイル端末からサーバーへ移したのか、そしてその決断が、これまで繰り返し手当てしてきた問題群をいかにして根本から消し去ったか。

破綻する運命にあったアプローチ

1.8.2 以前、チャット履歴はすべてモバイル端末がリアルタイムに組み立てていた。サーバーは agent のストリームイベントをモバイルへ送り出し、モバイルはそれらを順番にバブルとして描画し、目に見える対話を積み上げていた。

デスクトップ環境ではこれで十分だった。接続が安定しているからだ。しかしモバイルに来ると、この設計には根本的な欠陥があった。履歴は現在の接続と現在のセッションにしか存在しない。そこから問題が連鎖した。

  • 再接続後、新しい接続は以前のイベントを受け取れず、履歴は空白か断片だらけになる。
  • アプリを強制終了して再起動すると、メモリ上の対話が消え、最初からやり直しになる。
  • 複数デバイスが同じセッションに接続すると、それぞれ自分が接続した後のイベントしか見えず、各自がバラバラの履歴を持つ。

もう一つ、より見えにくい問題が順序にあった。メッセージ送信時、モバイル端末はユーザーのバブルを楽観的に先行表示する。一方、agent の最初の返信は、プロセスが実際に起動してから届く。その間に少しでもタイミングのばらつきが生じると——たとえば、キューに溜まっていたメッセージが再接続後に遅れて到着すると——ユーザーのバブルが返答より下に表示されてしまう。この「ユーザーバブルが末尾に落ちる」問題は何度も修正したが、いつも対症療法に留まった。根本原因は構造的なものだった。この設計には「この対話の本当の順序」に責任を持つ主体が、どこにも存在しなかった。

tmux の思想を借りる

Tactic Remote のターミナルモードがずっと安定している理由は tmux にある。tmux のモデルはシンプルかつ強力だ。ターミナルバッファが唯一の信頼できるソースであり、アタッチするということはその内容を手元に再生することを意味する。切断、デバイス変更、再起動——いずれも同じバッファに再接続するだけで、履歴は当然そこにある。

チャットモードに欠けていたのは、まさにこの層だった。そこで 1.8.2 の設計はこの思想を移植することにした。

サーバーが権威ある対話記録を保管し、クライアントが接続した際にそれを完全な形で復元する。

サーバーが順序の権威者になった瞬間、前述の問題群はもはや個別に修正すべきバグではなく、構造上あり得ない事象へと変わる。

具体的な実装

サーバー側:セッションごとのリングバッファ

サーバーは各チャットセッションに対して対話記録を保持する。ブロードキャストされたイベントはすべて順番にリングバッファへ追記される。バッファには二つの上限がある。イベント数の上限と総バイト数の上限で、超過した場合は最も古いものから削除される。ただし、セッション冒頭の初期化イベントは復元のアンカーとして永続する。削除が発生した場合、バッファに「切り詰め済み」のフラグを立て、復元時にクライアントが「より古いメッセージは省略されました」と上部に正直に表示できるようにする——完全な履歴があるかのように装うためではない。

ここで重要なステップがある。ユーザー自身が送ったメッセージも、サーバーが順序付きのイベントとして生成し、この記録に書き込む。従来、ユーザーのメッセージはモバイル側の楽観レンダリングにしか存在せず、サーバーはそれを把握していなかった。実行中に差し込んだメッセージ(steer)に至っては記録すら残らなかった。今やサーバーが順序の権威者となり、ユーザーのすべての発言がその実際に発生した位置に収まる。「ユーザーバブルが末尾に落ちる」という長年の問題も、構造上あり得なくなった。

復元:元のイベントを再送するのではなく、封筒に包む

最初の案は、接続時に元のイベントをそのまま再送するというものだった。このアイデアはすぐに却下された。古いバージョンのクライアントは未知のイベントを無視するが、再送されるのは認識しているイベントなので、新しいリアルタイムメッセージとして追記されてしまい、履歴が二重になる。

採用したのは、記録全体をまったく新しいイベント structured_replay に包んで送り出す方式だ。このイベントにはシーケンス番号、最後のバッチかどうか、切り詰められているかどうか、現在実行中かどうか、そしてイベントリストが含まれる。古いクライアントはこの新しいイベントを認識しないので、そのまま無視する——何も壊れない。新しいクライアントはこの「封筒」を開封し、中のイベントを同じ処理パイプラインに流して対話全体を再構成する。

iOS の WebSocket はフレームあたりの受信上限が約 1 MiB のため、復元時には 256 KB ずつに分割して送り、シーケンス番号で前後関係を示す。処理済みのツール承認については、復元時に「許可済み / 拒否済み」の状態を上乗せする。これにより、過去の承認カードが状態不明のまま、再操作を求めているかのように誤表示されるのを防ぐ。

クライアント側:接続即再構成、副作用は遮断

クライアントはシーケンス番号 0 の復元データを受け取ると、まずローカルの対話をクリアし、イベントリストを順番に同じ処理関数に流して再構成する。つまり復元はリアルタイムとまったく同じ描画パスを通るため、専用のロジックを別途書く必要がない。

対処しなければならない細部がある。復元中は、リアルタイムのみで発生すべき副作用を遮断しなければならない。キューの自動送信、タイムアウトのウォッチドッグ、実行状態の反転、承認の自動準備完了——これらをフラグ一つで一括してブロックし、最後のバッチが到着した後に封筒の権威ある実行状態を採用する。

送信時の楽観的なユーザーバブルは廃止しなかった。最初のメッセージは agent プロセスの起動に 1〜2 秒かかるため、復元を待つだけでは空白が生じてしまい、体験が悪い。代わりに「確認待ち」のプレースホルダーへ変え、サーバーから確認が返ってきた時点でプレースホルダーを取り除き、権威ある記録上の正しい位置に再挿入する。順序の乱れは、構造上継続できなくなった。

互換性:未知のイベントを無視することが設計上の決断である理由

この復元方式が安全に展開できた大きな要因は、早い段階から設けていた取り決めにある。クライアントは未知のイベントに出会ったら、無条件で無視する。この一点があるからこそ、サーバーは structured_replay のような新しいイベントを導入しても、まだアップグレードしていない古いクライアントに何の影響も与えない。彼らには復元機能が見えないが、それによって壊れることも一切ない。

iOS アプリ、Mac / Linux / Windows 三つのサーバー、npm CLI がそれぞれ独立してリリースされ、バージョンが長期にわたって食い違うシステムでは、「追記のみ・未知は無視」というプロトコル規律こそが、サーバーを先行させてクライアントが後から追いかけることを可能にする前提条件だ。

限界、そして誠実なトレードオフ

この設計に限界がないわけではない。それを明確に伝えることを選ぶ。

  • サーバーバッファには上限がある。 非常に長いセッションでは最初の部分が削除されることがあり、復元時には「より古いメッセージは省略されました」と明示する。完全であるかのように偽ることはしない。
  • 回収されたセッションはゼロから始まる。 セッションが長時間誰からも購読されなければ、サーバーはそれを回収する。その後 claude --resume で再開されたセッションは、バッファが空の状態から再構築され、それ以前の履歴は再生されない。これは意図的なトレードオフだ。有限の履歴保持と引き換えに、制御可能なメモリ使用量を得る。
  • クライアント側のローカル永続化は次のステップ。 現時点では、この権威ある記録はサーバーがオンラインである間にしか存在しない。履歴をクライアントのローカルにも保存することは次のフェーズの拡張であり、このバージョンには含まれない。

ある日突然履歴が一部消えていることに気づいてから原因を自分で探らせるより、これらの限界を前もって書き記しておく方を選ぶ。


この「接続即復元」を実際に体験したい方には、iOS アプリが App Store で無料公開されており、コンパニオンアプリと CLI も無料だ。このバージョンのもう一つの難題——ビジネスロジックの外にある UI のフリーズ——に興味があれば、「UIフリーズを追う」もあわせて読んでほしい。

Tactic Remote を試す

スマートフォンからコーディングエージェントを操作

Mac、Windows、Linux 上の Claude Code や Codex などに接続。iPhone や iPad で進捗を確認し、次の指示を送れます。