update: tuiからspawnする際にエラー詳細が落ちていた問題を修正
This commit is contained in:
parent
f18cf7c172
commit
5fbb9c47dd
2
TODO.md
2
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)
|
||||
|
|
|
|||
|
|
@ -53,7 +53,6 @@ pub struct App {
|
|||
pub current_tool: Option<String>,
|
||||
pub input: InputBuffer,
|
||||
pub quit: bool,
|
||||
pub shutdown_confirm: Option<std::time::Instant>,
|
||||
/// 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(),
|
||||
|
|
|
|||
|
|
@ -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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
|
|
@ -224,31 +223,18 @@ async fn run_spawn(resume_from: Option<SessionId>) -> Result<(), Box<dyn std::er
|
|||
let SpawnReady {
|
||||
pod_name,
|
||||
socket_path,
|
||||
mut child,
|
||||
stderr_drain,
|
||||
} = ready;
|
||||
|
||||
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
|
||||
// (drained off-line by `stderr_drain`) cannot collide with the
|
||||
// restored scrollback.
|
||||
// Leave alt-screen explicitly before `main`'s terminal restore path.
|
||||
let _ = execute!(
|
||||
terminal.backend_mut(),
|
||||
DisableMouseCapture,
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -264,7 +250,6 @@ async fn run(
|
|||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
pod_name: String,
|
||||
socket_path: &std::path::Path,
|
||||
shutdown_pod_on_exit: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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<CrosstermBackend<io::Stdout>>,
|
||||
app: &mut App,
|
||||
mut client: PodClient,
|
||||
shutdown_pod_on_exit: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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<Method> {
|
|||
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<Method> {
|
|||
|
||||
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`.
|
||||
/// Idle / Paused → 2-tap to quit the TUI (the Pod keeps running).
|
||||
fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
|
||||
|
|
|
|||
|
|
@ -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<SpawnReady, SpawnError> {
|
||||
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::<Vec<_>>().join(" | ")
|
||||
}
|
||||
fn into_string(self) -> String {
|
||||
self.lines.into_iter().collect::<Vec<_>>().join(" | ")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <pod-name>` で再接続できる。
|
||||
|
||||
## 履歴メモ
|
||||
|
||||
- かつて存在した `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` ほか
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user