diff --git a/TODO.md b/TODO.md index f956c076..750526c6 100644 --- a/TODO.md +++ b/TODO.md @@ -11,7 +11,7 @@ - Run 中の入力キューイング → [tickets/tui-input-queue.md](tickets/tui-input-queue.md) - ユーザーマニフェストのモデル設定 wizard → [tickets/tui-user-model-setup.md](tickets/tui-user-model-setup.md) - auto-kick 由来ターンが描画されない → [tickets/tui-pod-event-render.md](tickets/tui-pod-event-render.md) - - spawn 失敗時、Pod の stderr 末尾(同名衝突など)が TUI に出ず "pod exited before becoming ready" だけになる + - spawn 失敗時に Pod の stderr が TUI に表示されない → [tickets/tui-spawn-error-surface.md](tickets/tui-spawn-error-surface.md) - Manifest: Tool Output / File Upload 上限の分離とデフォルト緩和 → [tickets/manifest-output-upload-limits.md](tickets/manifest-output-upload-limits.md) - メモリ機構 - 使用頻度メトリクス + Knowledge 化候補レポート → [tickets/memory-usage-metrics.md](tickets/memory-usage-metrics.md) diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index d6324f34..d7cb9f6b 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -53,7 +53,6 @@ pub struct App { pub current_tool: Option, pub input: InputBuffer, pub quit: bool, - pub shutdown_confirm: Option, /// 2-tap guard for `Ctrl-C` when the Pod is not running. First press /// records the instant; a second press within the timeout exits the /// TUI (the Pod itself stays alive). @@ -86,7 +85,6 @@ impl App { current_tool: None, input: InputBuffer::new(), quit: false, - shutdown_confirm: None, quit_confirm: None, blocks: Vec::new(), scroll: Scroll::default(), diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index f00bbe5c..b754b083 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -12,7 +12,6 @@ mod ui; use std::io; use std::path::PathBuf; use std::process::ExitCode; -use std::time::Duration; use crossterm::event::{ self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, @@ -201,7 +200,7 @@ async fn run_attach( ) -> Result<(), Box> { let socket_path = resolve_socket(&pod_name, socket_override); let mut terminal = enter_fullscreen()?; - run(&mut terminal, pod_name, &socket_path, false).await + run(&mut terminal, pod_name, &socket_path).await } async fn run_resume() -> Result<(), Box> { @@ -224,31 +223,18 @@ async fn run_spawn(resume_from: Option) -> Result<(), Box {} - _ => { - let _ = child.start_kill(); - let _ = child.wait().await; - } - } - stderr_drain.abort(); - result } @@ -264,7 +250,6 @@ async fn run( terminal: &mut Terminal>, pod_name: String, socket_path: &std::path::Path, - shutdown_pod_on_exit: bool, ) -> Result<(), Box> { let mut app = App::new(pod_name); @@ -272,7 +257,7 @@ async fn run( Ok(mut client) => { app.connected = true; let _ = client.send(&Method::GetHistory).await; - run_loop(terminal, &mut app, client, shutdown_pod_on_exit).await?; + run_loop(terminal, &mut app, client).await?; } Err(e) => { app.push_error(format!( @@ -290,15 +275,11 @@ async fn run_loop( terminal: &mut Terminal>, app: &mut App, mut client: PodClient, - shutdown_pod_on_exit: bool, ) -> Result<(), Box> { terminal.draw(|f| ui::draw(f, app))?; loop { if app.quit { - if shutdown_pod_on_exit { - let _ = client.send(&Method::Shutdown).await; - } break; } @@ -414,10 +395,12 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option { KeyCode::Char('x') if ctrl => Some(if app.running { Some(Method::Cancel) } else { - app.push_error("Nothing to cancel (Pod is not running)."); - None + Some(Method::Shutdown) }), - KeyCode::Char('d') if ctrl => Some(handle_shutdown(app)), + KeyCode::Char('d') if ctrl => { + app.quit = true; + Some(None) + } KeyCode::Enter if alt => { app.insert_newline(); Some(app.refresh_completion()) @@ -550,21 +533,6 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option { const CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); -fn handle_shutdown(app: &mut App) -> Option { - if !app.running { - return Some(Method::Shutdown); - } - if let Some(t) = app.shutdown_confirm - && t.elapsed() < CONFIRM_TIMEOUT - { - app.shutdown_confirm = None; - return Some(Method::Shutdown); - } - app.shutdown_confirm = Some(std::time::Instant::now()); - app.push_error("Turn is running. Press Ctrl-D again to cancel and shut down."); - None -} - /// Running → send `Method::Pause`. /// Idle / Paused → 2-tap to quit the TUI (the Pod keeps running). fn handle_pause_or_quit(app: &mut App) -> Option { diff --git a/crates/tui/src/spawn.rs b/crates/tui/src/spawn.rs index 586235b3..0d5479f5 100644 --- a/crates/tui/src/spawn.rs +++ b/crates/tui/src/spawn.rs @@ -3,9 +3,9 @@ //! Rendered at the user's current cursor position when `tui` is invoked //! with no positional argument. Walks the cwd for a `.insomnia/manifest.toml` //! to seed defaults, prompts for the Pod's name, and on confirmation -//! launches the `pod` binary as a subprocess with a freshly built +//! launches the `pod` binary as an independent process with a freshly built //! overlay (name + cwd scope when no project manifest exists). Once -//! the child reports its socket via the `INSOMNIA-READY` stderr line, +//! the process reports its socket via the `INSOMNIA-READY` stderr line, //! the dialog hands control back so main can switch the terminal to //! alternate-screen mode. //! @@ -29,9 +29,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::{Frame, TerminalOptions, Viewport}; use session_store::SessionId; -use tokio::io::{AsyncBufReadExt, BufReader}; -use tokio::process::{Child, Command}; -use tokio::task::JoinHandle; +use tokio::process::Command; const READY_PREFIX: &str = "INSOMNIA-READY\t"; const VIEWPORT_LINES: u16 = 6; @@ -40,8 +38,6 @@ const READY_TIMEOUT: Duration = Duration::from_secs(20); pub struct SpawnReady { pub pod_name: String, pub socket_path: PathBuf, - pub child: Child, - pub stderr_drain: JoinHandle<()>, } pub enum SpawnOutcome { @@ -290,6 +286,16 @@ async fn wait_for_ready( let pod_bin = resolve_pod_command(); let cwd = std::env::current_dir().map_err(SpawnError::Io)?; + let pod_runtime_dir = manifest::paths::pod_runtime_dir(&form.name).ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "could not resolve runtime directory (set INSOMNIA_HOME, INSOMNIA_RUNTIME_DIR, XDG_RUNTIME_DIR, or HOME)", + ) + })?; + std::fs::create_dir_all(&pod_runtime_dir).map_err(SpawnError::Io)?; + let stderr_path = pod_runtime_dir.join("stderr.log"); + let stderr_file = std::fs::File::create(&stderr_path).map_err(SpawnError::Io)?; + let mut command = Command::new(&pod_bin); command .arg("--overlay") @@ -297,77 +303,151 @@ async fn wait_for_ready( .current_dir(&cwd) .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .kill_on_drop(true); + .stderr(Stdio::from(stderr_file)) + .process_group(0); if let Some(id) = form.resume_from { command.arg("--session").arg(id.to_string()); } let mut child = command.spawn().map_err(SpawnError::PodLaunchFailed)?; - let stderr = child - .stderr - .take() - .expect("stderr is piped; take() must succeed"); - let mut reader = BufReader::new(stderr).lines(); - let mut tail = StderrTail::new(); + // Default `kill_on_drop = false` plus `process_group(0)` makes this + // a detached Pod for TUI lifecycle purposes once startup succeeds: + // dropping the handle does not terminate it, and terminal-generated + // signals for the TUI's process group do not hit the Pod. Runtime + // state/socket files are the source of truth after that point. + let ready = match wait_for_ready_file(terminal, form, &stderr_path, &mut child).await { + Ok(ready) => ready, + Err(e) => { + let _ = child.start_kill(); + let _ = child.wait().await; + return Err(e); + } + }; + tokio::spawn(async move { + let _ = child.wait().await; + }); + Ok(ready) +} - let timeout = tokio::time::sleep(READY_TIMEOUT); - tokio::pin!(timeout); +async fn wait_for_ready_file( + terminal: &mut InlineTerminal, + form: &mut Form, + stderr_path: &std::path::Path, + child: &mut tokio::process::Child, +) -> Result { + let mut tail = StderrTail::new(); + let deadline = tokio::time::Instant::now() + READY_TIMEOUT; + let mut offset = 0usize; loop { - tokio::select! { - line = reader.next_line() => { - match line { - Ok(Some(line)) => { - if let Some(rest) = line.strip_prefix(READY_PREFIX) { - let mut parts = rest.splitn(2, '\t'); - let pod_name = parts.next().unwrap_or("").to_string(); - let socket_str = parts.next().unwrap_or("").to_string(); - if pod_name.is_empty() || socket_str.is_empty() { - return Err(SpawnError::PodExitedEarly { - stderr_tail: format!("malformed ready line: {line}"), - }); - } - let socket_path = PathBuf::from(socket_str); - - let stderr_drain = tokio::spawn(async move { - while let Ok(Some(_)) = reader.next_line().await {} - }); - - return Ok(SpawnReady { - pod_name, - socket_path, - child, - stderr_drain, - }); - } - tail.push(&line); - form.message = Some((line, MessageKind::Progress)); - let _ = terminal.draw(|f| draw_form(f, form)); - } - Ok(None) => { - let _ = child.wait().await; + let content = match tokio::fs::read_to_string(stderr_path).await { + Ok(content) => content, + Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(), + Err(e) => return Err(SpawnError::Io(e)), + }; + if content.len() > offset { + for line in content[offset..].lines() { + if let Some(rest) = line.strip_prefix(READY_PREFIX) { + let mut parts = rest.splitn(2, '\t'); + let pod_name = parts.next().unwrap_or("").to_string(); + let socket_str = parts.next().unwrap_or("").to_string(); + if pod_name.is_empty() || socket_str.is_empty() { return Err(SpawnError::PodExitedEarly { - stderr_tail: tail.into_string(), + stderr_tail: format!("malformed ready line: {line}"), }); } - Err(e) => return Err(SpawnError::Io(e)), + let socket_path = PathBuf::from(socket_str); + wait_for_socket( + &socket_path, + deadline, + child, + stderr_path, + &mut tail, + &mut offset, + ) + .await?; + return Ok(SpawnReady { + pod_name, + socket_path, + }); } + tail.push(line); + form.message = Some((line.to_string(), MessageKind::Progress)); + let _ = terminal.draw(|f| draw_form(f, form)); } + offset = content.len(); + } + + if tokio::time::Instant::now() >= deadline { + return Err(SpawnError::Timeout); + } + tokio::select! { status = child.wait() => { let _ = status; + // Pod は exit 直前に最終 stderr 行を flush することがある。 + // child.wait() が解決した後に再読みして、原因行を取りこ + // ぼさず PodExitedEarly に載せる。 + drain_stderr_into_tail(stderr_path, &mut tail, &mut offset).await; return Err(SpawnError::PodExitedEarly { stderr_tail: tail.into_string(), }); } - _ = &mut timeout => { - let _ = child.start_kill(); - return Err(SpawnError::Timeout); - } + _ = tokio::time::sleep(Duration::from_millis(100)) => {} } } } +async fn wait_for_socket( + socket_path: &std::path::Path, + deadline: tokio::time::Instant, + child: &mut tokio::process::Child, + stderr_path: &std::path::Path, + tail: &mut StderrTail, + offset: &mut usize, +) -> Result<(), SpawnError> { + loop { + match tokio::net::UnixStream::connect(socket_path).await { + Ok(_) => return Ok(()), + Err(e) + if e.kind() == io::ErrorKind::NotFound + || e.kind() == io::ErrorKind::ConnectionRefused => {} + Err(e) => return Err(SpawnError::Io(e)), + } + if tokio::time::Instant::now() >= deadline { + return Err(SpawnError::Timeout); + } + tokio::select! { + status = child.wait() => { + let _ = status; + drain_stderr_into_tail(stderr_path, tail, offset).await; + return Err(SpawnError::PodExitedEarly { + stderr_tail: tail.as_string(), + }); + } + _ = tokio::time::sleep(Duration::from_millis(50)) => {} + } + } +} + +async fn drain_stderr_into_tail( + stderr_path: &std::path::Path, + tail: &mut StderrTail, + offset: &mut usize, +) { + let Ok(content) = tokio::fs::read_to_string(stderr_path).await else { + return; + }; + if content.len() <= *offset { + return; + } + for line in content[*offset..].lines() { + if !line.starts_with(READY_PREFIX) { + tail.push(line); + } + } + *offset = content.len(); +} + fn build_overlay_toml(form: &Form) -> String { let mut root = toml::value::Table::new(); @@ -449,6 +529,9 @@ impl StderrTail { } self.lines.push_back(line.to_string()); } + fn as_string(&self) -> String { + self.lines.iter().cloned().collect::>().join(" | ") + } fn into_string(self) -> String { self.lines.into_iter().collect::>().join(" | ") } diff --git a/docs/tui-keybindings.md b/docs/tui-keybindings.md index bfe16cb3..e00a08ec 100644 --- a/docs/tui-keybindings.md +++ b/docs/tui-keybindings.md @@ -90,32 +90,34 @@ Paused 中に Enter すると、入力の有無で 2 通り: | キー | Running 中 | Idle / Paused | |---|---|---| -| `Ctrl-X` | `Method::Cancel`(進行中ターンを破棄 → Idle) | no-op(エラー表示のみ) | +| `Ctrl-X` | `Method::Cancel`(進行中ターンを破棄 → Idle) | `Method::Shutdown`(Pod を終了) | | `Ctrl-C` | `Method::Pause`(進行中ターンを中断 → Paused) | 1 回目 warn、3 秒以内の 2 回目で TUI 終了(Pod は残る) | -| `Ctrl-D` | 1 回目 warn、3 秒以内の 2 回目で `Method::Shutdown` | `Method::Shutdown`(Pod を終了) | +| `Ctrl-D` | TUI 終了(Pod は残る、Pause しない) | TUI 終了(Pod は残る) | ### Cancel と Pause の違い - **Cancel** は「ターンを捨てる」: 進行中の LLM リクエスト・未完了 tool を打ち切り、状態は Idle。続きは Resume できない - **Pause** は「止めるけど続けられるように」: 同じく打ち切るが状態は Paused、空 Enter で Resume 可能 -Running 中に割り込みたい場合、ほとんどのケースで `Ctrl-C`(Pause)が自然。Ctrl-X(Cancel)は明示的に破棄したい時(LLM が暴走した時など)用。 +Running 中に割り込みたい場合、ほとんどのケースで `Ctrl-C`(Pause)が自然。Ctrl-X(Cancel)は明示的に破棄したい時(LLM が暴走した時など)用。Pod を終了したい場合は、先に Running ではない状態(Idle / Paused)にしてから `Ctrl-X` で Shutdown する。 -### Ctrl-C と Ctrl-D の 2 段階 UX - -どちらも「破壊的に見える操作」は確認を挟む: +### Ctrl-C と Ctrl-D の終了 UX +- Ctrl-X Running 中: `Method::Cancel`。終了したい場合は、明示的にターンを止めて Idle に戻してからもう一度 `Ctrl-X` +- Ctrl-X Idle / Paused: `Method::Shutdown` を送って Pod を終了 - Ctrl-C Running 中: 1 回目で即 Pause(破壊的ではない) - Ctrl-C Idle / Paused: 1 回目で warn メッセージ、3 秒以内の 2 回目で TUI 終了(Pod は残る) -- Ctrl-D Running 中: 1 回目で warn、3 秒以内の 2 回目で Shutdown -- Ctrl-D Idle / Paused: 1 回目で即 Shutdown +- Ctrl-D: 状態に関わらず即 TUI 終了(Pod は残る)。Running 中でも Pause / Cancel / Shutdown は送らない -`Ctrl-C` は Pod は落とさず TUI プロセスだけ抜ける。`Ctrl-D` は Pod 自体に `Method::Shutdown` を送って終了させる(Pod プロセスが消える)。 +`Ctrl-X` は Running 中だけ Cancel、Idle / Paused では Shutdown。`Ctrl-C` は Running 中だけ Pod に `Method::Pause` を送り、それ以外では Pod は落とさず TUI プロセスだけ抜ける。`Ctrl-D` は常に Pod へ制御メソッドを送らず TUI プロセスだけ抜ける。 + +TUI のダイアログから Pod を起動する経路では、起動した Pod は TUI の子プロセスとして管理・終了されず、独立したプロセスとして残る。TUI 終了後は `tui ` で再接続できる。 ## 履歴メモ - かつて存在した `Ctrl-R`(Resume 専用)は、空 Enter での Resume に統合されたため廃止 - かつて存在した `Esc`(TUI 終了)は、`Ctrl-C` の 2 連打 UX に統合されたため廃止 +- かつて `Ctrl-D` は Pod に `Method::Shutdown` を送っていたが、TUI だけを抜けるデタッチ操作に変更された - 旧 inline viewport 時代は履歴スクロールを端末側スクロールバックに 任せていたため TUI 内のスクロールキーは存在しなかった。全画面 alt screen への移行(`tickets/tui-fullscreen-overhaul.md`)で `Shift-Up/Down` ほか