feat: Podのステータスを厳密にし、同期漏れを防ぐ

This commit is contained in:
2026-05-04 12:55:11 +09:00
parent a0771608b1
commit 560c23bc75
24 changed files with 641 additions and 311 deletions
+43 -9
View File
@@ -1,7 +1,8 @@
use std::time::Instant;
use protocol::{
AlertLevel, AlertSource, CompletionEntry, CompletionKind, Event, Method, RunResult, Segment,
AlertLevel, AlertSource, CompletionEntry, CompletionKind, Event, Method, PodStatus, RunResult,
Segment,
};
use crate::block::{
@@ -41,11 +42,16 @@ impl CompletionState {
pub struct App {
pub pod_name: String,
pub connected: bool,
/// Last controller status reported by the Pod. Drives the status line
/// and Ctrl-key routing; do not infer this solely from replayed history.
pub pod_status: PodStatus,
/// True while the Pod is in `PodStatus::Running`.
pub running: bool,
/// True while the Pod is in `PodStatus::Paused`. Set on
/// `RunEnd::Paused` and cleared when a new turn starts (either via
/// `Resume` or a fresh `Run`).
/// True while the Pod is in `PodStatus::Paused`.
pub paused: bool,
/// True after worker `RunEnd` while controller post-run work is still
/// blocking the next method.
pub busy: bool,
pub run_requests: usize,
/// Sum of `input_tokens - cache_read_input_tokens` across the
/// current turn's LLM requests — i.e. the net tokens this turn
@@ -80,8 +86,10 @@ impl App {
Self {
pod_name,
connected: false,
pod_status: PodStatus::Idle,
running: false,
paused: false,
busy: false,
run_requests: 0,
run_upload_tokens: 0,
run_output_tokens: 0,
@@ -99,6 +107,16 @@ impl App {
}
}
pub fn set_pod_status(&mut self, status: PodStatus) {
self.pod_status = status;
self.running = status == PodStatus::Running;
self.paused = status == PodStatus::Paused;
self.busy = status == PodStatus::Busy;
if self.running || self.busy {
self.quit_confirm = None;
}
}
/// Re-evaluate the completion popup against the current input.
/// Returns a `Method::ListCompletions` to send when the
/// `(kind, prefix_start, prefix)` triple changed; otherwise `None`.
@@ -278,6 +296,10 @@ impl App {
}
pub fn submit_input(&mut self) -> Option<Method> {
if self.busy {
self.push_error("Pod is finishing post-run work; wait for idle before submitting.");
return None;
}
let segments = self.input.submit_segments();
if segments_are_blank(&segments) {
// Empty Enter only does something meaningful when the Pod
@@ -450,8 +472,7 @@ impl App {
self.assistant_streaming = false;
}
Event::TurnStart { .. } => {
self.running = true;
self.paused = false;
self.set_pod_status(PodStatus::Running);
self.run_requests += 1;
self.current_tool = None;
self.assistant_streaming = false;
@@ -617,8 +638,10 @@ impl App {
upload_tokens: self.run_upload_tokens,
output_tokens: self.run_output_tokens,
});
self.running = false;
self.paused = matches!(result, RunResult::Paused);
self.set_pod_status(match result {
RunResult::Paused => PodStatus::Paused,
RunResult::Finished | RunResult::LimitReached => PodStatus::Busy,
});
self.run_requests = 0;
self.run_upload_tokens = 0;
self.run_output_tokens = 0;
@@ -643,8 +666,16 @@ impl App {
message: alert.message,
});
}
Event::History { items, greeting } => {
Event::History {
items,
greeting,
status,
} => {
self.restore_history(&items, greeting);
self.set_pod_status(status);
}
Event::Status { status } => {
self.set_pod_status(status);
}
Event::Completions { kind, entries } => {
// Apply only if the popup is still on the same
@@ -1216,8 +1247,11 @@ mod completion_flow_tests {
"text": "[File: src/main.rs]\nfn main() {}",
}],
})],
status: PodStatus::Running,
});
assert!(matches!(app.pod_status, PodStatus::Running));
assert!(app.running);
assert!(matches!(
app.blocks.get(1),
Some(Block::SystemMessage { text }) if text == "[File: src/main.rs]\nfn main() {}"
+4
View File
@@ -35,6 +35,10 @@ impl PodClient {
self.writer.write(method).await
}
pub fn try_next_event(&mut self) -> Option<Event> {
self.event_rx.try_recv().ok()
}
pub async fn next_event(&mut self) -> Option<Event> {
self.event_rx.recv().await
}
+72 -29
View File
@@ -21,7 +21,7 @@ use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use protocol::Method;
use protocol::{Method, PodStatus};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use session_store::SessionId;
@@ -283,29 +283,44 @@ async fn run_loop(
break;
}
// Drain any already-buffered Pod events in a bounded batch before
// polling the terminal. This keeps status fresh without letting a
// busy event stream starve Ctrl-C / Ctrl-X input.
for _ in 0..32 {
match client.try_next_event() {
Some(ev) => app.handle_pod_event(ev),
None => break,
}
}
// Always give the terminal queue a non-blocking pass each frame.
// The awaited select below only waits after this pass found nothing.
let mut handled_term_event = false;
while event::poll(std::time::Duration::ZERO)? {
handled_term_event = true;
handle_terminal_event(app, &mut client, event::read()?).await?;
if app.quit {
break;
}
}
if app.quit {
break;
}
if handled_term_event {
terminal.draw(|f| ui::draw(f, app))?;
continue;
}
tokio::select! {
_ = tokio::task::spawn_blocking(|| event::poll(std::time::Duration::from_millis(50))) => {
while event::poll(std::time::Duration::ZERO)? {
match event::read()? {
TermEvent::Key(key) => {
if let Some(method) = handle_key(app, key) {
client.send(&method).await?;
}
}
TermEvent::Mouse(mouse) => {
handle_mouse(app, mouse);
}
TermEvent::Paste(s) => {
app.insert_paste(s);
}
TermEvent::Resize(_, _) => {
// No-op: next draw repaints in full.
}
_ => {}
}
if app.quit {
break;
}
term_event = tokio::task::spawn_blocking(|| {
if event::poll(std::time::Duration::from_millis(50))? {
event::read().map(Some)
} else {
Ok(None)
}
}) => {
if let Some(term_event) = term_event?? {
handle_terminal_event(app, &mut client, term_event).await?;
}
}
event = client.next_event(), if app.connected => {
@@ -325,6 +340,31 @@ async fn run_loop(
Ok(())
}
async fn handle_terminal_event(
app: &mut App,
client: &mut PodClient,
event: TermEvent,
) -> Result<(), Box<dyn std::error::Error>> {
match event {
TermEvent::Key(key) => {
if let Some(method) = handle_key(app, key) {
client.send(&method).await?;
}
}
TermEvent::Mouse(mouse) => {
handle_mouse(app, mouse);
}
TermEvent::Paste(s) => {
app.insert_paste(s);
}
TermEvent::Resize(_, _) => {
// No-op: next draw repaints in full.
}
_ => {}
}
Ok(())
}
fn run_disconnected(_app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
loop {
if event::poll(std::time::Duration::from_millis(100))?
@@ -392,10 +432,13 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
Some(app.refresh_completion())
}
KeyCode::Char('c') if ctrl => Some(handle_pause_or_quit(app)),
KeyCode::Char('x') if ctrl => Some(if app.running {
Some(Method::Cancel)
} else {
Some(Method::Shutdown)
KeyCode::Char('x') if ctrl => Some(match app.pod_status {
PodStatus::Running => Some(Method::Cancel),
PodStatus::Paused | PodStatus::Idle => Some(Method::Shutdown),
PodStatus::Busy => {
app.push_error("Pod is finishing post-run work; wait for idle or press Ctrl-C twice to exit the TUI.");
None
}
}),
KeyCode::Char('d') if ctrl => {
app.quit = true;
@@ -534,9 +577,9 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
const CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
/// Running → send `Method::Pause`.
/// Idle / Paused → 2-tap to quit the TUI (the Pod keeps running).
/// Idle / Paused / Busy → 2-tap to quit the TUI (the Pod keeps running).
fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
if app.running {
if app.pod_status == PodStatus::Running {
return Some(Method::Pause);
}
if let Some(t) = app.quit_confirm
+12
View File
@@ -877,6 +877,18 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) {
" — Enter to resume, type to start new turn",
Style::default().fg(Color::DarkGray),
));
} else if app.busy {
spans.push(Span::raw(" | "));
spans.push(Span::styled(
"busy",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
" — finishing post-run work",
Style::default().fg(Color::DarkGray),
));
} else {
spans.push(Span::styled(" idle", Style::default().fg(Color::DarkGray)));
}