feat(tui): wake an idle goal for finished background shells; keep the goal visible
Two operate-loop truths from the morning report and captains-log #12: Continuation after internal events — sub-agent completions already wake the idle engine, but background shell completion is pull-only: nothing re-entered the turn loop when a job finished, so an active goal waiting on background work sat inert until the user re-prompted. The idle receive now arms a coarse 750ms poll only while a goal is active and an unclaimed background completion may exist (reusing the manager's read-only pending signal), and on readiness queues a normal goal continuation. The continuation dispatch path already re-reads live goal state — pause/budget/clear all still win — and handle_send_message's boundary drain claims the evidence exactly as a user turn would. Without an active goal nothing changes: completions keep waiting for the next user-initiated turn, proven by the disarmed-timeout regression. Goal visibility — the sidebar goal banner dies with a hidden sidebar, so a set goal was invisible chrome-wide. The footer now carries an unconditional goal chip beside the shell chip: truncated objective plus the continuation pass while hunting, and an explicit 'goal paused' state so a stalled loop is never silent. Terminal verdicts clear the chip. Verified: cargo fmt clean; new wake regression passes (including the no-goal disarmed case); goal 89, footer 105, shell 270 suites green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -775,9 +775,19 @@ enum SendMessageOutcome {
|
||||
},
|
||||
}
|
||||
|
||||
/// Idle-poll cadence for unclaimed background shell completion while a
|
||||
/// goal is active. Coarse on purpose: this is a liveness backstop, not an
|
||||
/// animation loop.
|
||||
const SHELL_WAKE_POLL_MS: u64 = 750;
|
||||
|
||||
enum EngineRunInput {
|
||||
Operation(Box<Op>),
|
||||
SubAgentCompletion(SubAgentCompletion),
|
||||
/// A background shell job finished while the engine sat idle with an
|
||||
/// active goal. Shell completion is pull-only (no channel), so without
|
||||
/// this wake an active goal waiting on background work stayed inert until
|
||||
/// the user typed something (morning-report continuation gap).
|
||||
ShellCompletionWake,
|
||||
}
|
||||
|
||||
impl SendMessageOutcome {
|
||||
@@ -1908,15 +1918,70 @@ impl Engine {
|
||||
.await
|
||||
.map(|op| EngineRunInput::Operation(Box::new(op)))
|
||||
} else {
|
||||
tokio::select! {
|
||||
op = self.rx_op.recv() => op.map(|op| EngineRunInput::Operation(Box::new(op))),
|
||||
completion = self.rx_subagent_completion.recv(), if !host_managed_turns => {
|
||||
completion.map(EngineRunInput::SubAgentCompletion)
|
||||
loop {
|
||||
let shell_wake_armed = !host_managed_turns && self.idle_shell_wake_armed();
|
||||
tokio::select! {
|
||||
op = self.rx_op.recv() => {
|
||||
return op.map(|op| EngineRunInput::Operation(Box::new(op)));
|
||||
}
|
||||
completion = self.rx_subagent_completion.recv(), if !host_managed_turns => {
|
||||
return completion.map(EngineRunInput::SubAgentCompletion);
|
||||
}
|
||||
// Background shells have no completion channel, so an
|
||||
// idle engine polls only while a goal is active and a
|
||||
// background job is outstanding; the arm disarms itself
|
||||
// the moment either condition clears.
|
||||
() = tokio::time::sleep(Duration::from_millis(SHELL_WAKE_POLL_MS)), if shell_wake_armed => {
|
||||
if self.finished_background_shell_pending() {
|
||||
return Some(EngineRunInput::ShellCompletionWake);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the idle loop should poll for background shell completion:
|
||||
/// only while a goal is active and a background job is running or has
|
||||
/// finished without being claimed yet.
|
||||
fn idle_shell_wake_armed(&self) -> bool {
|
||||
let goal_active = self
|
||||
.config
|
||||
.goal_state
|
||||
.lock()
|
||||
.map(|state| state.snapshot().is_active())
|
||||
.unwrap_or(false);
|
||||
if !goal_active {
|
||||
return false;
|
||||
}
|
||||
self.shell_manager
|
||||
.lock()
|
||||
.map(|manager| manager.may_have_undelivered_completion())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether a finished background job is waiting to be claimed.
|
||||
fn finished_background_shell_pending(&self) -> bool {
|
||||
self.shell_manager
|
||||
.lock()
|
||||
.map(|mut manager| manager.has_finished_unreported_jobs())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// An idle-engine wake for finished background shell work: queue a goal
|
||||
/// continuation. The evidence itself is claimed by the boundary drain in
|
||||
/// `handle_send_message`, so the continuation turn reads the completion
|
||||
/// payload the same way a user-initiated turn would.
|
||||
async fn handle_idle_shell_completion_wake(&mut self) {
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::status(
|
||||
"Background shell work finished; continuing the active goal".to_string(),
|
||||
))
|
||||
.await;
|
||||
self.schedule_goal_continuation(Vec::new());
|
||||
}
|
||||
|
||||
/// Run the engine event loop
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub async fn run(mut self) {
|
||||
@@ -1945,6 +2010,9 @@ impl Engine {
|
||||
EngineRunInput::SubAgentCompletion(completion) => {
|
||||
self.handle_idle_subagent_completion(completion).await;
|
||||
}
|
||||
EngineRunInput::ShellCompletionWake => {
|
||||
self.handle_idle_shell_completion_wake().await;
|
||||
}
|
||||
EngineRunInput::Operation(op) => match *op {
|
||||
Op::SendMessage {
|
||||
content,
|
||||
|
||||
@@ -14624,3 +14624,86 @@ async fn cacheable_prefix_is_byte_stable_across_unchanged_turns() {
|
||||
"the system prompt must not churn on an unchanged-mode turn"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idle_engine_wakes_for_finished_background_shell_only_while_goal_active() {
|
||||
// Morning-report continuation gap: background shell completion is
|
||||
// pull-only, so an idle engine with an active goal never learned the job
|
||||
// finished and the goal sat inert until the user typed something.
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let config = EngineConfig {
|
||||
snapshots_enabled: false,
|
||||
terminal_chrome_enabled: false,
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
..Default::default()
|
||||
};
|
||||
let (mut engine, _handle) = Engine::new(config, &Config::default());
|
||||
|
||||
let _task_id = {
|
||||
let mut shell = engine.shell_manager.lock().expect("shell manager");
|
||||
let started = shell
|
||||
.execute_with_options_env_for_owner(
|
||||
"echo shell-wake-done",
|
||||
None,
|
||||
30_000,
|
||||
true,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
std::collections::HashMap::new(),
|
||||
None,
|
||||
)
|
||||
.expect("start background job");
|
||||
started.task_id.expect("background task id")
|
||||
};
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
let done = {
|
||||
let mut shell = engine.shell_manager.lock().expect("shell manager");
|
||||
shell.has_finished_unreported_jobs()
|
||||
};
|
||||
if done {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"background job never finished"
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
|
||||
// No active goal: the wake stays disarmed and the idle receive keeps
|
||||
// waiting — completions belong to the next user-initiated turn.
|
||||
let disarmed =
|
||||
tokio::time::timeout(Duration::from_millis(300), engine.next_run_input(false)).await;
|
||||
assert!(
|
||||
disarmed.is_err(),
|
||||
"without an active goal the engine must not start turns for shell completions"
|
||||
);
|
||||
|
||||
engine
|
||||
.config
|
||||
.goal_state
|
||||
.lock()
|
||||
.expect("goal state")
|
||||
.sync_from_host_status(
|
||||
Some("finish the background verification"),
|
||||
None,
|
||||
crate::tools::goal::GoalStatus::Active,
|
||||
);
|
||||
|
||||
let input = tokio::time::timeout(Duration::from_secs(10), engine.next_run_input(false))
|
||||
.await
|
||||
.expect("idle engine must wake for finished background shell work")
|
||||
.expect("engine input");
|
||||
assert!(
|
||||
matches!(input, EngineRunInput::ShellCompletionWake),
|
||||
"wake input expected"
|
||||
);
|
||||
|
||||
engine.handle_idle_shell_completion_wake().await;
|
||||
assert!(
|
||||
engine.has_scheduled_goal_continuation(),
|
||||
"the wake must queue a goal continuation that will claim the evidence"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2208,6 +2208,17 @@ impl ShellManager {
|
||||
jobs
|
||||
}
|
||||
|
||||
/// Whether a finished job's completion is waiting to be claimed. Unlike
|
||||
/// [`Self::may_have_undelivered_completion`] this polls, so it reports
|
||||
/// readiness the moment the process exits; the engine's idle shell wake
|
||||
/// uses it to fire exactly when evidence exists.
|
||||
pub(crate) fn has_finished_unreported_jobs(&mut self) -> bool {
|
||||
self.processes.values_mut().any(|shell| {
|
||||
shell.poll();
|
||||
shell.status != ShellStatus::Running && !shell.completion_reported
|
||||
})
|
||||
}
|
||||
|
||||
/// Drain once-only completion events together with lossless stream bytes.
|
||||
/// The engine publishes the bytes outside this manager's mutex and puts
|
||||
/// only the bounded event plus resulting handle into model context.
|
||||
|
||||
@@ -760,7 +760,16 @@ pub(crate) fn render_footer_from(
|
||||
// Right-cluster extension chips: append in `items` order so user
|
||||
// ordering is preserved across the new variants.
|
||||
let mut extra: Vec<Span<'static>> = Vec::new();
|
||||
// Goal chip first: like the shell chip it is unconditional chrome, and an
|
||||
// autonomous loop the user cannot see is worse than a busy footer.
|
||||
let goal_chip = footer_goal_spans(app);
|
||||
if !goal_chip.is_empty() {
|
||||
extra.extend(goal_chip);
|
||||
}
|
||||
if !shell_chip.is_empty() {
|
||||
if !extra.is_empty() {
|
||||
extra.push(Span::raw(" "));
|
||||
}
|
||||
extra.extend(shell_chip);
|
||||
}
|
||||
for item in items {
|
||||
@@ -820,6 +829,52 @@ pub(crate) fn footer_git_branch_spans(app: &App) -> Vec<Span<'static>> {
|
||||
)]
|
||||
}
|
||||
|
||||
/// Active-goal chip: a set goal stays visible in the always-on footer chrome
|
||||
/// (captains-log #12 — `create_goal` worked but the goal vanished once set
|
||||
/// whenever the sidebar was hidden). Shows the truncated objective while the
|
||||
/// goal hunts, and names the paused state so a stalled goal is never silent.
|
||||
fn footer_goal_spans(app: &App) -> Vec<Span<'static>> {
|
||||
let theme = &app.ui_theme;
|
||||
let (objective, paused) = match (&app.hunt.quarry, &app.paused_quarry) {
|
||||
(Some(objective), _) => {
|
||||
if matches!(
|
||||
app.hunt.verdict,
|
||||
crate::tui::app::HuntVerdict::Hunted | crate::tui::app::HuntVerdict::Escaped
|
||||
) {
|
||||
return Vec::new();
|
||||
}
|
||||
(
|
||||
objective.clone(),
|
||||
app.hunt.verdict == crate::tui::app::HuntVerdict::Wounded,
|
||||
)
|
||||
}
|
||||
(None, Some(objective)) => (objective.clone(), true),
|
||||
(None, None) => return Vec::new(),
|
||||
};
|
||||
let mut label = objective.trim().replace(['\n', '\r'], " ");
|
||||
if label.chars().count() > 32 {
|
||||
label = label.chars().take(31).collect::<String>() + "…";
|
||||
}
|
||||
let mut spans = vec![Span::styled(
|
||||
if paused { "goal paused " } else { "goal " }.to_string(),
|
||||
Style::default()
|
||||
.fg(if paused {
|
||||
theme.warning
|
||||
} else {
|
||||
theme.status_working
|
||||
})
|
||||
.add_modifier(ratatui::style::Modifier::BOLD),
|
||||
)];
|
||||
spans.push(Span::styled(label, Style::default().fg(theme.text_muted)));
|
||||
if !paused && app.hunt.continuation_count > 0 {
|
||||
spans.push(Span::styled(
|
||||
format!(" · pass {}", app.hunt.continuation_count),
|
||||
Style::default().fg(theme.text_muted),
|
||||
));
|
||||
}
|
||||
spans
|
||||
}
|
||||
|
||||
fn footer_shell_spans(app: &App) -> Vec<Span<'static>> {
|
||||
if let Some(label) = active_foreground_shell_label(app) {
|
||||
return crate::tui::widgets::footer_shell_label_chip(label);
|
||||
|
||||
Reference in New Issue
Block a user