From cf9c830c5539cb38f7158aa1ce122b53b29ef242 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:08:15 +0800 Subject: [PATCH] fix(board): a run just started is Running, not Review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Between Start and the agent showing up in its tab — the shell starting, the command being typed — the run's tab has no agent in it, and a run with no agent read as finished: the card jumped to Review and back. A run whose tab is open and which started under two minutes ago now reads as starting (task::STARTING, an agent yet to take its first turn), which is Running. Past the grace, a run that never started is over after all. The same for `tty7 task ls`. --- crates/tty7-cli/src/commands.rs | 32 +++++++++++++++++++++++++++++-- crates/tty7-core/src/core/task.rs | 13 +++++++++++++ src/ui/board/mod.rs | 22 +++++++++++++++++++-- 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/crates/tty7-cli/src/commands.rs b/crates/tty7-cli/src/commands.rs index 0bff414b..39cc2beb 100644 --- a/crates/tty7-cli/src/commands.rs +++ b/crates/tty7-cli/src/commands.rs @@ -1208,12 +1208,17 @@ fn run_live( AgentStatus::Done => 1, AgentStatus::Idle => 0, }; - tab.root + let live = tab + .root .pane_ids() .iter() .filter_map(|p| states.get(p)) .max_by_key(|s| urgency(s.status)) - .map(tty7_core::core::task::Live::from) + .map(tty7_core::core::task::Live::from); + // Its tab is open and nothing has reported yet: still starting. + let fresh = tty7_core::core::machine::unix_now().saturating_sub(run.started) + < tty7_core::core::task::START_GRACE_SECS; + live.or(fresh.then_some(tty7_core::core::task::STARTING)) } fn column_name(column: tty7_core::core::task::Column) -> &'static str { @@ -2652,6 +2657,29 @@ mod tests { assert_eq!(columns, vec!["queued", "needs-input"]); } + #[test] + fn a_run_whose_agent_has_not_shown_up_yet_is_running_not_over() { + use tty7_core::core::task::{Run, Task}; + let mut backend = mock(); + let ws = &mut backend.machine.workspaces[0]; + let tab = ws.tabs[0].id; + let mut fresh = Task::new("just started"); + fresh.push_run(Run { + agent: CLIAgent::Claude, + tab: Some(tab), + session_id: None, + started: tty7_core::core::machine::unix_now(), + worktree: None, + }); + ws.tasks = vec![fresh]; + let out = json_of(run_cli( + &["tty7", "task", "ls", "api"], + &Context::default(), + &mut backend, + )); + assert_eq!(out["tasks"][0]["column"], "running"); + } + #[test] fn task_done_finds_the_task_by_the_start_of_its_id() { use tty7_core::core::task::Task; diff --git a/crates/tty7-core/src/core/task.rs b/crates/tty7-core/src/core/task.rs index 1cd58803..13b2577e 100644 --- a/crates/tty7-core/src/core/task.rs +++ b/crates/tty7-core/src/core/task.rs @@ -36,6 +36,19 @@ pub const MAX_PROMPT_BYTES: usize = 16 * 1024; /// resumed and nobody resumes the tenth attempt at the same thing. pub const MAX_RUNS: usize = 8; +/// How long a run's tab may take to show its agent before the run counts as +/// never having started: a shell's startup, then the agent's own. Until then +/// a run with its tab open and no agent in it is starting, not over — see +/// [`STARTING`]. +pub const START_GRACE_SECS: u64 = 120; + +/// What a run that is still starting reads as: an agent that has not taken +/// its first turn, which [`column`] puts in Running. +pub const STARTING: Live = Live { + status: AgentStatus::Idle, + turns: 0, +}; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] #[serde(transparent)] pub struct TaskId(uuid::Uuid); diff --git a/src/ui/board/mod.rs b/src/ui/board/mod.rs index ba87e380..00d23333 100644 --- a/src/ui/board/mod.rs +++ b/src/ui/board/mod.rs @@ -366,7 +366,25 @@ impl Tty7App { let local = self.can_spawn_locally(cx); let mut cards = Vec::new(); for task in &self.board.tasks { - let column = task::column(task, |run| find(run.tab).map(|f| f.live)); + // A run whose tab is open but whose agent has not shown up yet — + // the shell is still starting, the command still being typed — + // is starting, not over. Read as an agent that has not taken its + // first turn, which `column` counts as Running. Past the grace a + // run that never started is over after all. + let now = unix_now(); + let starting = |run: &Run| { + run.tab.is_some_and(|id| { + find(Some(id)).is_none() + && self.tabs.iter().any(|t| t.tree_id.get() == id) + && now.saturating_sub(run.started) < task::START_GRACE_SECS + }) + }; + let column = task::column(task, |run| { + find(run.tab) + .map(|f| f.live) + .or_else(|| starting(run).then_some(task::STARTING)) + }); + let starting_tab = task.runs.last().filter(|r| starting(r)).and_then(|r| r.tab); // The run this card reports: the newest one still open, else the // newest one at all. let open = task.runs.iter().rev().find_map(|r| find(r.tab)); @@ -397,7 +415,7 @@ impl Tty7App { Column::Queued => task.paused.or(Some(task.created)), _ => last.map(|r| r.started), }, - tab: open.map(|f| f.id), + tab: open.map(|f| f.id).or(starting_tab), resumable: open.is_none() && last.is_some_and(|r| r.session_id.is_some()), paused: task.paused.is_some(), worktree_gone: local