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:
Hmbown
2026-08-02 01:45:58 -07:00
parent 97580ea152
commit 614ef72a07
4 changed files with 221 additions and 4 deletions
+72 -4
View File
@@ -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,
+83
View File
@@ -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"
);
}
+11
View File
@@ -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.
+55
View File
@@ -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);