fix: SpawnPodの起動経路の問題・を修正

This commit is contained in:
Keisuke Hirata 2026-05-02 01:09:57 +09:00
parent e97f803104
commit 6bf1f9a110
6 changed files with 178 additions and 7 deletions

View File

@ -1,3 +1,5 @@
[memory]
extract_threshold = 10000
extract_threshold = 20000
consolidation_threshold_files = 10
# consolidation_threshold_bytes = 0

View File

@ -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)

View File

@ -268,11 +268,12 @@ async fn wait_for_ready(
form: &mut Form,
overlay_toml: &str,
) -> Result<SpawnReady, SpawnError> {
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<String>) {
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 {

View File

@ -11,6 +11,7 @@ pkgs.mkShell {
pkg-config
openssl
];
INSOMNIA_POD_COMMAND = "cargo run -p pod --quiet --";
shellHook = ''
echo "dev-shell-loaded"
'';

View File

@ -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 の実装(プロバイダ側に存在しないので不可能)
- 課金額の自動上限制御

View File

@ -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` として上に流す。
schemeOpenAI / 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-streamSSE 読み中)の継続再開 → `llm-worker-stream-continuation`
- プロバイダ別の細かい retry policy共通既定で十分
- リトライ上限値の manifest からの上書き必要になったら別ticket