update: tuiからspawnする際にエラー詳細が落ちていた問題を修正

This commit is contained in:
Keisuke Hirata 2026-05-03 21:47:54 +09:00
parent c693126703
commit c214ea79d4
No known key found for this signature in database
5 changed files with 159 additions and 108 deletions

View File

@ -11,7 +11,7 @@
- Run 中の入力キューイング → [tickets/tui-input-queue.md](tickets/tui-input-queue.md) - Run 中の入力キューイング → [tickets/tui-input-queue.md](tickets/tui-input-queue.md)
- ユーザーマニフェストのモデル設定 wizard → [tickets/tui-user-model-setup.md](tickets/tui-user-model-setup.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) - 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) - 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) - 使用頻度メトリクス + Knowledge 化候補レポート → [tickets/memory-usage-metrics.md](tickets/memory-usage-metrics.md)

View File

@ -53,7 +53,6 @@ pub struct App {
pub current_tool: Option<String>, pub current_tool: Option<String>,
pub input: InputBuffer, pub input: InputBuffer,
pub quit: bool, pub quit: bool,
pub shutdown_confirm: Option<std::time::Instant>,
/// 2-tap guard for `Ctrl-C` when the Pod is not running. First press /// 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 /// records the instant; a second press within the timeout exits the
/// TUI (the Pod itself stays alive). /// TUI (the Pod itself stays alive).
@ -86,7 +85,6 @@ impl App {
current_tool: None, current_tool: None,
input: InputBuffer::new(), input: InputBuffer::new(),
quit: false, quit: false,
shutdown_confirm: None,
quit_confirm: None, quit_confirm: None,
blocks: Vec::new(), blocks: Vec::new(),
scroll: Scroll::default(), scroll: Scroll::default(),

View File

@ -12,7 +12,6 @@ mod ui;
use std::io; use std::io;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::ExitCode; use std::process::ExitCode;
use std::time::Duration;
use crossterm::event::{ use crossterm::event::{
self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
@ -201,7 +200,7 @@ async fn run_attach(
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let socket_path = resolve_socket(&pod_name, socket_override); let socket_path = resolve_socket(&pod_name, socket_override);
let mut terminal = enter_fullscreen()?; 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<dyn std::error::Error>> { async fn run_resume() -> Result<(), Box<dyn std::error::Error>> {
@ -224,31 +223,18 @@ async fn run_spawn(resume_from: Option<SessionId>) -> Result<(), Box<dyn std::er
let SpawnReady { let SpawnReady {
pod_name, pod_name,
socket_path, socket_path,
mut child,
stderr_drain,
} = ready; } = ready;
let mut terminal = enter_fullscreen()?; let mut terminal = enter_fullscreen()?;
let result = run(&mut terminal, pod_name, &socket_path, true).await; let result = run(&mut terminal, pod_name, &socket_path).await;
// Leave alt-screen before reaping the child so any final pod stderr // Leave alt-screen explicitly before `main`'s terminal restore path.
// (drained off-line by `stderr_drain`) cannot collide with the
// restored scrollback.
let _ = execute!( let _ = execute!(
terminal.backend_mut(), terminal.backend_mut(),
DisableMouseCapture, DisableMouseCapture,
LeaveAlternateScreen LeaveAlternateScreen
); );
match tokio::time::timeout(Duration::from_secs(3), child.wait()).await {
Ok(Ok(_)) => {}
_ => {
let _ = child.start_kill();
let _ = child.wait().await;
}
}
stderr_drain.abort();
result result
} }
@ -264,7 +250,6 @@ async fn run(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
pod_name: String, pod_name: String,
socket_path: &std::path::Path, socket_path: &std::path::Path,
shutdown_pod_on_exit: bool,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let mut app = App::new(pod_name); let mut app = App::new(pod_name);
@ -272,7 +257,7 @@ async fn run(
Ok(mut client) => { Ok(mut client) => {
app.connected = true; app.connected = true;
let _ = client.send(&Method::GetHistory).await; 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) => { Err(e) => {
app.push_error(format!( app.push_error(format!(
@ -290,15 +275,11 @@ async fn run_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App, app: &mut App,
mut client: PodClient, mut client: PodClient,
shutdown_pod_on_exit: bool,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
terminal.draw(|f| ui::draw(f, app))?; terminal.draw(|f| ui::draw(f, app))?;
loop { loop {
if app.quit { if app.quit {
if shutdown_pod_on_exit {
let _ = client.send(&Method::Shutdown).await;
}
break; break;
} }
@ -414,10 +395,12 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
KeyCode::Char('x') if ctrl => Some(if app.running { KeyCode::Char('x') if ctrl => Some(if app.running {
Some(Method::Cancel) Some(Method::Cancel)
} else { } else {
app.push_error("Nothing to cancel (Pod is not running)."); Some(Method::Shutdown)
None
}), }),
KeyCode::Char('d') if ctrl => Some(handle_shutdown(app)), KeyCode::Char('d') if ctrl => {
app.quit = true;
Some(None)
}
KeyCode::Enter if alt => { KeyCode::Enter if alt => {
app.insert_newline(); app.insert_newline();
Some(app.refresh_completion()) Some(app.refresh_completion())
@ -550,21 +533,6 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
const CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); const CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
fn handle_shutdown(app: &mut App) -> Option<Method> {
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`. /// Running → send `Method::Pause`.
/// Idle / Paused → 2-tap to quit the TUI (the Pod keeps running). /// Idle / Paused → 2-tap to quit the TUI (the Pod keeps running).
fn handle_pause_or_quit(app: &mut App) -> Option<Method> { fn handle_pause_or_quit(app: &mut App) -> Option<Method> {

View File

@ -3,9 +3,9 @@
//! Rendered at the user's current cursor position when `tui` is invoked //! Rendered at the user's current cursor position when `tui` is invoked
//! with no positional argument. Walks the cwd for a `.insomnia/manifest.toml` //! with no positional argument. Walks the cwd for a `.insomnia/manifest.toml`
//! to seed defaults, prompts for the Pod's name, and on confirmation //! 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 //! 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 //! the dialog hands control back so main can switch the terminal to
//! alternate-screen mode. //! alternate-screen mode.
//! //!
@ -29,9 +29,7 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph; use ratatui::widgets::Paragraph;
use ratatui::{Frame, TerminalOptions, Viewport}; use ratatui::{Frame, TerminalOptions, Viewport};
use session_store::SessionId; use session_store::SessionId;
use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Command;
use tokio::process::{Child, Command};
use tokio::task::JoinHandle;
const READY_PREFIX: &str = "INSOMNIA-READY\t"; const READY_PREFIX: &str = "INSOMNIA-READY\t";
const VIEWPORT_LINES: u16 = 6; const VIEWPORT_LINES: u16 = 6;
@ -40,8 +38,6 @@ const READY_TIMEOUT: Duration = Duration::from_secs(20);
pub struct SpawnReady { pub struct SpawnReady {
pub pod_name: String, pub pod_name: String,
pub socket_path: PathBuf, pub socket_path: PathBuf,
pub child: Child,
pub stderr_drain: JoinHandle<()>,
} }
pub enum SpawnOutcome { pub enum SpawnOutcome {
@ -290,6 +286,16 @@ async fn wait_for_ready(
let pod_bin = resolve_pod_command(); let pod_bin = resolve_pod_command();
let cwd = std::env::current_dir().map_err(SpawnError::Io)?; 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); let mut command = Command::new(&pod_bin);
command command
.arg("--overlay") .arg("--overlay")
@ -297,77 +303,151 @@ async fn wait_for_ready(
.current_dir(&cwd) .current_dir(&cwd)
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::piped()) .stderr(Stdio::from(stderr_file))
.kill_on_drop(true); .process_group(0);
if let Some(id) = form.resume_from { if let Some(id) = form.resume_from {
command.arg("--session").arg(id.to_string()); command.arg("--session").arg(id.to_string());
} }
let mut child = command.spawn().map_err(SpawnError::PodLaunchFailed)?; let mut child = command.spawn().map_err(SpawnError::PodLaunchFailed)?;
let stderr = child // Default `kill_on_drop = false` plus `process_group(0)` makes this
.stderr // a detached Pod for TUI lifecycle purposes once startup succeeds:
.take() // dropping the handle does not terminate it, and terminal-generated
.expect("stderr is piped; take() must succeed"); // signals for the TUI's process group do not hit the Pod. Runtime
let mut reader = BufReader::new(stderr).lines(); // state/socket files are the source of truth after that point.
let mut tail = StderrTail::new(); 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); async fn wait_for_ready_file(
tokio::pin!(timeout); terminal: &mut InlineTerminal,
form: &mut Form,
stderr_path: &std::path::Path,
child: &mut tokio::process::Child,
) -> Result<SpawnReady, SpawnError> {
let mut tail = StderrTail::new();
let deadline = tokio::time::Instant::now() + READY_TIMEOUT;
let mut offset = 0usize;
loop { loop {
tokio::select! { let content = match tokio::fs::read_to_string(stderr_path).await {
line = reader.next_line() => { Ok(content) => content,
match line { Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(),
Ok(Some(line)) => { Err(e) => return Err(SpawnError::Io(e)),
if let Some(rest) = line.strip_prefix(READY_PREFIX) { };
let mut parts = rest.splitn(2, '\t'); if content.len() > offset {
let pod_name = parts.next().unwrap_or("").to_string(); for line in content[offset..].lines() {
let socket_str = parts.next().unwrap_or("").to_string(); if let Some(rest) = line.strip_prefix(READY_PREFIX) {
if pod_name.is_empty() || socket_str.is_empty() { let mut parts = rest.splitn(2, '\t');
return Err(SpawnError::PodExitedEarly { let pod_name = parts.next().unwrap_or("").to_string();
stderr_tail: format!("malformed ready line: {line}"), let socket_str = parts.next().unwrap_or("").to_string();
}); if pod_name.is_empty() || socket_str.is_empty() {
}
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;
return Err(SpawnError::PodExitedEarly { 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() => { status = child.wait() => {
let _ = status; let _ = status;
// Pod は exit 直前に最終 stderr 行を flush することがある。
// child.wait() が解決した後に再読みして、原因行を取りこ
// ぼさず PodExitedEarly に載せる。
drain_stderr_into_tail(stderr_path, &mut tail, &mut offset).await;
return Err(SpawnError::PodExitedEarly { return Err(SpawnError::PodExitedEarly {
stderr_tail: tail.into_string(), stderr_tail: tail.into_string(),
}); });
} }
_ = &mut timeout => { _ = tokio::time::sleep(Duration::from_millis(100)) => {}
let _ = child.start_kill();
return Err(SpawnError::Timeout);
}
} }
} }
} }
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 { fn build_overlay_toml(form: &Form) -> String {
let mut root = toml::value::Table::new(); let mut root = toml::value::Table::new();
@ -449,6 +529,9 @@ impl StderrTail {
} }
self.lines.push_back(line.to_string()); self.lines.push_back(line.to_string());
} }
fn as_string(&self) -> String {
self.lines.iter().cloned().collect::<Vec<_>>().join(" | ")
}
fn into_string(self) -> String { fn into_string(self) -> String {
self.lines.into_iter().collect::<Vec<_>>().join(" | ") self.lines.into_iter().collect::<Vec<_>>().join(" | ")
} }

View File

@ -90,32 +90,34 @@ Paused 中に Enter すると、入力の有無で 2 通り:
| キー | Running 中 | Idle / Paused | | キー | 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-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 と Pause の違い
- **Cancel** は「ターンを捨てる」: 進行中の LLM リクエスト・未完了 tool を打ち切り、状態は Idle。続きは Resume できない - **Cancel** は「ターンを捨てる」: 進行中の LLM リクエスト・未完了 tool を打ち切り、状態は Idle。続きは Resume できない
- **Pause** は「止めるけど続けられるように」: 同じく打ち切るが状態は Paused、空 Enter で Resume 可能 - **Pause** は「止めるけど続けられるように」: 同じく打ち切るが状態は Paused、空 Enter で Resume 可能
Running 中に割り込みたい場合、ほとんどのケースで `Ctrl-C`Pauseが自然。Ctrl-XCancelは明示的に破棄したい時LLM が暴走した時など)用。 Running 中に割り込みたい場合、ほとんどのケースで `Ctrl-C`Pauseが自然。Ctrl-XCancelは明示的に破棄したい時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 Running 中: 1 回目で即 Pause破壊的ではない
- Ctrl-C Idle / Paused: 1 回目で warn メッセージ、3 秒以内の 2 回目で TUI 終了Pod は残る) - Ctrl-C Idle / Paused: 1 回目で warn メッセージ、3 秒以内の 2 回目で TUI 終了Pod は残る)
- Ctrl-D Running 中: 1 回目で warn、3 秒以内の 2 回目で Shutdown - Ctrl-D: 状態に関わらず即 TUI 終了Pod は残る。Running 中でも Pause / Cancel / Shutdown は送らない
- Ctrl-D Idle / Paused: 1 回目で即 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 <pod-name>` で再接続できる。
## 履歴メモ ## 履歴メモ
- かつて存在した `Ctrl-R`Resume 専用)は、空 Enter での Resume に統合されたため廃止 - かつて存在した `Ctrl-R`Resume 専用)は、空 Enter での Resume に統合されたため廃止
- かつて存在した `Esc`TUI 終了)は、`Ctrl-C` の 2 連打 UX に統合されたため廃止 - かつて存在した `Esc`TUI 終了)は、`Ctrl-C` の 2 連打 UX に統合されたため廃止
- かつて `Ctrl-D` は Pod に `Method::Shutdown` を送っていたが、TUI だけを抜けるデタッチ操作に変更された
- 旧 inline viewport 時代は履歴スクロールを端末側スクロールバックに - 旧 inline viewport 時代は履歴スクロールを端末側スクロールバックに
任せていたため TUI 内のスクロールキーは存在しなかった。全画面 alt screen 任せていたため TUI 内のスクロールキーは存在しなかった。全画面 alt screen
への移行(`tickets/tui-fullscreen-overhaul.md`)で `Shift-Up/Down` ほか への移行(`tickets/tui-fullscreen-overhaul.md`)で `Shift-Up/Down` ほか