From 5479b144117a8b0627a5a6ab08aedac4b7aff17e Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 2 May 2026 01:09:57 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20SpawnPod=E3=81=AE=E8=B5=B7=E5=8B=95?= =?UTF-8?q?=E7=B5=8C=E8=B7=AF=E3=81=AE=E5=95=8F=E9=A1=8C=E3=83=BB=E3=82=92?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .insomnia/manifest.toml | 4 +- TODO.md | 3 + crates/tui/src/spawn.rs | 20 ++++-- devshell.nix | 1 + tickets/llm-worker-stream-continuation.md | 87 +++++++++++++++++++++++ tickets/llm-worker-transient-retry.md | 70 ++++++++++++++++++ 6 files changed, 178 insertions(+), 7 deletions(-) create mode 100644 tickets/llm-worker-stream-continuation.md create mode 100644 tickets/llm-worker-transient-retry.md diff --git a/.insomnia/manifest.toml b/.insomnia/manifest.toml index d3484a2b..722578e7 100644 --- a/.insomnia/manifest.toml +++ b/.insomnia/manifest.toml @@ -1,3 +1,5 @@ [memory] -extract_threshold = 10000 +extract_threshold = 20000 + consolidation_threshold_files = 10 +# consolidation_threshold_bytes = 0 diff --git a/TODO.md b/TODO.md index 33d923f1..3a2db242 100644 --- a/TODO.md +++ b/TODO.md @@ -7,6 +7,9 @@ - [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md) - [ ] Pod CLI: マニフェスト関連フラグの整理 → [tickets/pod-cli-manifest-flags.md](tickets/pod-cli-manifest-flags.md) - [ ] OpenAI Responses: sampling パラメータの取り扱い → [tickets/responses-sampling-params.md](tickets/responses-sampling-params.md) +- [ ] llm-worker のエラー耐性 + - [ ] HTTP transient リトライ → [tickets/llm-worker-transient-retry.md](tickets/llm-worker-transient-retry.md) + - [ ] ストリーム途中失敗時の継続 → [tickets/llm-worker-stream-continuation.md](tickets/llm-worker-stream-continuation.md) - [ ] Pod オーケストレーション - [ ] 動的 Scope 変更 → [tickets/dynamic-scope.md](tickets/dynamic-scope.md) - [ ] ネイティブ GUI クライアント MVP → [tickets/native-gui-mvp.md](tickets/native-gui-mvp.md) diff --git a/crates/tui/src/spawn.rs b/crates/tui/src/spawn.rs index 54d99a17..2cebe80b 100644 --- a/crates/tui/src/spawn.rs +++ b/crates/tui/src/spawn.rs @@ -268,11 +268,12 @@ async fn wait_for_ready( form: &mut Form, overlay_toml: &str, ) -> Result { - let pod_bin = resolve_pod_command(); + let (pod_bin, pod_args) = resolve_pod_command(); let cwd = std::env::current_dir().map_err(SpawnError::Io)?; let mut command = Command::new(&pod_bin); command + .args(&pod_args) .arg("--overlay") .arg(overlay_toml) .current_dir(&cwd) @@ -374,21 +375,28 @@ fn build_overlay_toml(form: &Form) -> String { toml::to_string(&toml::Value::Table(root)).expect("overlay serialisation cannot fail") } -fn resolve_pod_command() -> PathBuf { +/// Resolves the program (and any leading args) used to launch a child Pod. +/// +/// `INSOMNIA_POD_COMMAND` is split on whitespace so devshells can point it +/// at e.g. `cargo run -p pod --quiet --`; the first token is the program +/// and the rest are prepended before `--overlay` and friends. +fn resolve_pod_command() -> (PathBuf, Vec) { if let Ok(cmd) = std::env::var("INSOMNIA_POD_COMMAND") { - if !cmd.is_empty() { - return PathBuf::from(cmd); + let mut tokens = cmd.split_whitespace(); + if let Some(program) = tokens.next() { + let args = tokens.map(str::to_owned).collect(); + return (PathBuf::from(program), args); } } if let Ok(exe) = std::env::current_exe() { if let Some(dir) = exe.parent() { let candidate = dir.join("pod"); if candidate.is_file() { - return candidate; + return (candidate, Vec::new()); } } } - PathBuf::from("pod") + (PathBuf::from("pod"), Vec::new()) } struct StderrTail { diff --git a/devshell.nix b/devshell.nix index c659f8a6..275bbe00 100644 --- a/devshell.nix +++ b/devshell.nix @@ -11,6 +11,7 @@ pkgs.mkShell { pkg-config openssl ]; + INSOMNIA_POD_COMMAND = "cargo run -p pod --quiet --"; shellHook = '' echo "dev-shell-loaded" ''; diff --git a/tickets/llm-worker-stream-continuation.md b/tickets/llm-worker-stream-continuation.md new file mode 100644 index 00000000..e430e722 --- /dev/null +++ b/tickets/llm-worker-stream-continuation.md @@ -0,0 +1,87 @@ +# llm-worker: ストリーム途中失敗時の継続 + +## 背景 + +LLM 応答の SSE ストリームを読んでいる途中で upstream が切れると、 +`crates/llm-worker/src/llm_client/transport.rs:231` で +`ClientError::Sse(...)`(中身は `eventsource_stream::Error::Transport`、 +さらに reqwest の `error decoding response body`)として上に投げられ、 +`worker.rs:933 stream_response` が `WorkerError` に変換して Run 全体が中断する。 + +実例: セッション `019de419-6f8b-71a0-9e56-f0b9a6c7098b.jsonl:236`。 +直前まで text / tool_use を Timeline に積み続けていたが、 +最後の SSE フレームが届く前に接続が切れ、Run が落ちた。 + +ここで重要な点: + +- 出力トークンは upstream 側で既に発生しており、課金済み。 + 単純に同じ request を再送すると二重出力 + 二重課金。 +- Anthropic / OpenAI のいずれの API も、途切れた SSE を + resume するエンドポイントは持たない。継続したい場合は + 「assistant turn として部分出力を history に置いた状態で再リクエスト」 + という形を自作する必要がある。 +- 部分出力の質は内容依存: + - 完成した text ブロックは原則そのまま history に置ける + - tool_use の `input_json` が途中で切れたブロックは破損 JSON で、そのままは置けない + - reasoning / thinking ブロックも provider 依存の扱いが要る + +このため、これは「リトライ」ではなく「継続 (continuation)」。 +`history` を編集する責務であり、`transport.rs` には収まらず、 +`worker.rs` 層(または上位)の機能になる。 +`feedback_llm_worker_scope.md` の方針(llm-worker は低レベル基盤に留める)にも合致する。 + +なお `worker.rs:973` 付近で部分 `flush_usage()` だけは既に行っており、 +半分くらいは継続を意識した作りになっている。あとは +「壊れていないブロックの確定」と「次ターンの起動条件」を足す形。 + +## 詰めたい論点(実装前に決める) + +このチケットは仕様議論を含む。以下を確定させてから実装に入る。 + +1. **部分ブロックの取り扱い基準** + - 完成した text ブロックは確定して history に push するか + - 未完の tool_use は捨てるのが妥当か、暫定 stop で残すか + - reasoning / thinking ブロックの扱い(provider 別) + +2. **継続の起動方式** + - 自動的に次ターンを回す(= retry-like 挙動)/ Pause で上位に判断委譲 / + manifest で切替、のいずれか + - デフォルトは何か(自動継続は意図しない出費を生む可能性あり) + +3. **ループ防止** + - 同種の transport エラーが連続 N 回起きたら諦める閾値 + - 「ストリーム開始後ほぼ即座に切れる」が連続するパターンの検知 + +4. **他の中断要因との優先度** + - `Cancelled` / `Aborted` / interceptor の `Yield` が同時に起きたときの順序 + +5. **可観測性** + - 「途中で切れて継続した」事実をセッションログに残す形 + - `ClientError::Sse(String)` を `Sse { kind: Parse | Transport, msg }` に + 分割するかどうか(診断容易性のため) + +## 要件(暫定。論点確定後に再記述) + +- ストリーム途中で transport 由来のエラーが出た場合、 + `worker.rs` がそれを catch し、Timeline に積まれた完成ブロックだけを + assistant items として確定する。 +- 未完ブロック(特に壊れた tool_use)は破棄するか、 + 破棄したことを示す形で履歴に残す(決定は論点 1)。 +- 継続するか中断するかの判定が、論点 2 の決定に従って分岐する。 +- 連続失敗時に止まる(論点 3)。 +- 既存の `Cancelled` / `Aborted` パスが優先される(論点 4)。 + +## 完了条件 + +- 上記論点が決まり、ドキュメント or チケット本文に反映されている。 +- ストリーム途中で切れるモックを使った integration test が、 + 決まった仕様どおりに継続 or 中断する。 +- 課金重複が起きないこと(自動継続でも、過去ターンの再生成は発生しない)が + test または手動手順で確認されている。 +- `cargo check` / `cargo test` が `llm-worker` で通る。 + +## 範囲外 + +- pre-stream の transient リトライ → `llm-worker-transient-retry` +- ストリーム resume API の実装(プロバイダ側に存在しないので不可能) +- 課金額の自動上限制御 diff --git a/tickets/llm-worker-transient-retry.md b/tickets/llm-worker-transient-retry.md new file mode 100644 index 00000000..2a769830 --- /dev/null +++ b/tickets/llm-worker-transient-retry.md @@ -0,0 +1,70 @@ +# llm-worker: HTTP transient リトライ + +## 背景 + +`crates/llm-worker/src/llm_client/transport.rs` はリトライを持たず、 +upstream が一時的に不調だったときのエラーがそのまま `WorkerError` に +伝播して Run が中断する。実セッションでも以下が観測されている: + +- セッション `019de419-6f8b-71a0-9e56-f0b9a6c7098b.jsonl:85` で + `API error (status: 503): upstream connect error ... Connection refused`。 + これは `transport.rs:194-216` で `response.status().is_success()` 前に + 返される pre-stream の経路。リクエストはまだ消費されていない。 +- Anthropic の `overloaded_error` (529) や Codex backend の 503 も + 同経路で観測される transient な事象。 + +これらは「ヘッダが返る前の段階」で出るため、SSE を読み始めて +出力トークンを発生させる前であり、素朴な再送でべき等に復旧できる典型ケース。 +ストリームが途中で切れた場合のリカバリは別の話(→ `llm-worker-stream-continuation`)。 + +## 方針 + +`transport.rs` の HTTP 送信層に transient エラー向けの再送を追加する。 +SSE 読み出し開始後 (`response.bytes_stream()` 以降) のエラーは対象外で、 +従来どおり `ClientError::Sse` として上に流す。 + +scheme(OpenAI / Anthropic / Responses 等)に依存しない共通処理として、 +すべての client から同じ振る舞いで使える形にする。 + +## 要件 + +### リトライ対象 + +- HTTP ステータス: 408 / 425 / 429 / 500 / 502 / 503 / 504 / 529 +- `reqwest::Error::is_connect()` / `is_timeout()` 由来の送信失敗 +- それ以外の `ClientError::Api { status }` および `ClientError::Json`、 + ストリーム開始後の `ClientError::Sse` は対象外 + +判定は `is_retryable(&ClientError) -> bool` を `error.rs` に置いて一箇所に集約する。 + +### バックオフ + +- フルジッタ付き指数(base/cap は実装時に妥当な値で固定。後で manifest 化したくなったら別ticket) +- `Retry-After` ヘッダがあれば指数バックオフを上書きしてその時間待つ +- 上限: 最大試行回数 + 累積タイムアウトの両方を持つ + +### ログ + +- リトライ発火ごとに `warn!`(ステータス、attempt 番号、次の wait) + +### 既存挙動の温存 + +- ストリーム途中で切れた場合の挙動には手を入れない + (`transport.rs:231` の `ClientError::Sse` 経路はそのまま) +- 成功時のレイテンシに観測可能なオーバヘッドを足さない + +## 完了条件 + +- `is_retryable` のテーブル駆動 unit test +- 503 / 529 / connect refused をモックした unit test が、 + 規定回数までリトライして「最終的に成功」「上限到達でエラー」の両ケースを通る +- `Retry-After: 5` を返すモックでは指数を上書きして 5s 待っている + (仮想時間で検証) +- mid-stream で `ClientError::Sse` を起こすモックでリトライが発火しない +- `cargo check` / `cargo test` が `llm-worker` で通る + +## 範囲外 + +- mid-stream(SSE 読み中)の継続再開 → `llm-worker-stream-continuation` +- プロバイダ別の細かい retry policy(共通既定で十分) +- リトライ上限値の manifest からの上書き(必要になったら別ticket)