アーカイブ済み(2026-09-15): 本ドキュメントの内容は../ui-brain-protocol.mdの「冪等性(request_id)」節に統合された。今後の参照・更新はそちらを使うこと。本書は議論の経緯を追う場合の参考としてのみ残す。
ステータス: 設計合意済み・実装未着手
関連: wsl-brain-separation-plan.md(このドキュメントは同計画の「クライアントは...通信エラーの表示・読み上げもクライアントの責務とする」を具体化する一部)。voice-volume-control-plan.mdの設計中に必要性が判明した(Codexレビュー指摘、詳細はそちらの「対象外」節参照)。
このドキュメントが定義するのは、Windows(UI層)がBrainへの/ask呼び出しを安全に再試行できるようにするための、Brain⇄Windows間のプロトコル(request_idによる冪等性)だけである。
次の事項は個別のUI層(Windows側)の実装判断であり、本ドキュメントの対象外とする:
プロトコルはこれらの判断を可能にする土台(冪等な再送)だけを提供する。UI層の具体的な振る舞いは各UIが自分で決めてよい(音声UIとテキストUIで異なってもよい)。
/askは冪等ではない。呼び出すたびに、Brain側の会話履歴に追記し、adjust_volumeのようなclient actionも実行する。
Windows側がタイムアウトやネットワーク断で応答を受け取れなかった場合、Brain側では次のいずれかが起きている可能性がある:
このうち3番のケースで、Windows側が同じユーザー発話を単純に再送すると、Brain側で同じ発話がもう一度処理され、会話履歴の二重追加や、adjust_volumeのようなclient actionの二重適用が起こる。
request_idによる冪等な再送/askのリクエストにrequest_id(文字列、必須)を追加する。request_idを1つ生成し(例: uuid4())、その発話に対する全てのリトライで同じ値を使い回す。新しいユーザー発話には新しいrequest_idを生成する。session_idごとに、直近に処理したrequest_idとその結果を保持する。受け取ったrequest_idが保持している値と一致する場合、OllamaChat.ask()を再実行せず、保持している結果をそのまま返す。一致しない場合は通常通り処理し、結果を新しいrequest_idと共に保持し直す。これにより、上記の1〜3のどのケースでリトライが発生しても、Brain側の処理・副作用(会話履歴・client action)は最大1回しか起こらないことが保証される。
server.py)class AskRequest(BaseModel):
session_id: str
request_id: str
text: str
client_type: str
@dataclass
class Session:
chat: OllamaChat
lock: threading.Lock = field(default_factory=threading.Lock)
last_heartbeat: float = field(default_factory=time.monotonic)
last_request_id: str | None = None
last_result: AskResult | None = None
@app.post("/ask", response_model=AskResponse)
def ask(request: AskRequest) -> AskResponse:
if request.client_type not in ("voice", "text"):
raise HTTPException(status_code=400, detail="client_type must be 'voice' or 'text'")
session = _get_or_create(request.session_id)
with session.lock:
session.last_heartbeat = time.monotonic()
if request.request_id == session.last_request_id and session.last_result is not None:
result = session.last_result
else:
result = session.chat.ask(request.text, client_type=request.client_type)
session.last_request_id = request.request_id
session.last_result = result
return AskResponse(conversation=result.answer, control=result.control)
session.lockにより同一セッションへのリクエストは既に直列化されているため、「処理中に別スレッドが同時に同じrequest_idを処理してしまう」競合状態は発生しない(2番のケースで届いたリトライは、ロック取得まで待たされた後、上記の一致判定によってそのまま前回結果を受け取る)。
last_resultは直近1件だけを保持すればよい(古いrequest_idの結果を保持し続ける必要はない)。既存のセッション掃除処理(SESSION_TTL_SEC)の対象にもそのまま含まれる。
request_idのUUIDと機能的には同等。本設計では1セッションにつき常に高々1件しか処理中にならない(session.lockで直列化済み)ため、順序性を利用した追加の検知(古いリクエストの拒否等)をする必要がなく、カウンタの永続化・セッション再生成時のリセットを気にしなくていいUUIDの方がシンプルと判断した。tests/test_server.py: 同じrequest_idで/askを2回呼んでも、FakeChat.ask()(またはOllamaChat)が1回しか呼ばれず、同じ結果が返ることを確認する。tests/test_server.py: 異なるrequest_idなら、2回とも通常通り処理されることを確認する。