Show live background task counts

This commit is contained in:
iris-ai committed 2026-09-15 13:44:32 -04:00
1 parent 9fd21af4e8
commit 8262ceb786
16 files changed
+160 -29

No files matched your search

+4
View File
@@ -510,6 +510,10 @@ impl ClaudeDriver {
}
impl Driver for ClaudeDriver {
fn background_tasks(&self) -> Option<usize> {
self.state.lock().unwrap().background_task_count()
}
fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>) {
let mut content = Vec::new();
// An image goes into the message itself; the model looks at it. Any
+33 -18
View File
@@ -136,8 +136,9 @@ pub(super) struct Translator {
/// Claude's level signal for background work, when this CLI is new enough
/// to send one. Unlike `open_tasks`, this is a snapshot: each new value
/// replaces the old one, so a missed ending edge cannot leave work open
/// forever. See [`Translator::translate_background_tasks`].
background_tasks: Option<bool>,
/// forever. The count is also shown beside the session's status. See
/// [`Translator::translate_background_tasks`].
background_tasks: Option<usize>,
/// Tasks the level signal closed before their ordinary notification
/// arrived. That notification still owns the useful summary, so it gets
/// one chance to update the transcript or tool card after the status was
@@ -649,17 +650,18 @@ impl Translator {
};
let was_outstanding = self.work_outstanding();
// The CLI explicitly says not to correlate these ids with its edge
// stream. Their useful claim here is the level: empty or not.
self.background_tasks = Some(!tasks.is_empty());
// stream. Their useful claims here are the level and its exact size.
self.background_tasks = Some(tasks.len());
let mut events = vec![Event::BackgroundTasks { count: tasks.len() }];
// During a turn the snapshot omits a foreground task, so wait for the
// result boundary before using it to close anything. Between turns,
// every task that can still be alive is background work and the level
// can repair a missed notification immediately.
if !self.settled || self.in_turn {
return Vec::new();
return events;
}
let mut events = self.reconcile_background_tasks();
events.extend(self.reconcile_background_tasks());
let is_outstanding = self.work_outstanding();
if was_outstanding != is_outstanding {
events.push(Event::Status {
@@ -677,7 +679,7 @@ impl Translator {
/// foreground task can remain. Returns updates for background commands;
/// subagents carry the same correction in their own status transcript.
fn reconcile_background_tasks(&mut self) -> Vec<Event> {
let Some(false) = self.background_tasks else {
let Some(0) = self.background_tasks else {
return Vec::new();
};
let mut events = Vec::new();
@@ -717,11 +719,17 @@ impl Translator {
/// `session_running` is true by construction: this is only ever asked
/// while translating a line the session's process just wrote.
fn work_outstanding(&self) -> bool {
self.background_tasks == Some(true)
self.background_tasks.is_some_and(|count| count > 0)
|| !self.open_tasks.is_empty()
|| self.subagents.any_open(true)
}
/// The latest count Claude supplied, and `None` until this process has
/// supplied its first authoritative snapshot.
pub(super) fn background_task_count(&self) -> Option<usize> {
self.background_tasks
}
/// A task reporting back, from whichever of the two lines got here first.
///
/// Handled once. The two shapes can both arrive for one task, and what
@@ -1853,23 +1861,30 @@ mod tests {
let mut translator = Translator::new(dir.path().to_path_buf(), Arc::clone(&subagents));
translator.mark_settled();
assert!(
assert_eq!(
translate_lines(
&mut translator,
&[r#"{"type":"system","subtype":"background_tasks_changed","tasks":["toolu_stale"]}"#],
)
.is_empty()
&[
r#"{"type":"system","subtype":"background_tasks_changed","tasks":["toolu_stale","command-2","command-3","command-4","command-5"]}"#
],
),
vec![Event::BackgroundTasks { count: 5 }]
);
assert_eq!(translator.background_task_count(), Some(5));
assert_eq!(subagents.list(true)[0].status, SessionStatus::Running);
assert_eq!(
translate_lines(
&mut translator,
&[r#"{"type":"system","subtype":"background_tasks_changed","tasks":[]}"#],
),
vec![Event::Status {
state: SessionStatus::Idle
}]
vec![
Event::BackgroundTasks { count: 0 },
Event::Status {
state: SessionStatus::Idle
}
]
);
assert_eq!(translator.background_task_count(), Some(0));
assert_eq!(subagents.list(true)[0].status, SessionStatus::Exited);
}
@@ -1884,12 +1899,12 @@ mod tests {
let mut translator = Translator::new(dir.path().to_path_buf(), Arc::clone(&subagents));
translator.mark_running();
assert!(
assert_eq!(
translate_lines(
&mut translator,
&[r#"{"type":"system","subtype":"background_tasks_changed","tasks":[]}"#],
)
.is_empty()
),
vec![Event::BackgroundTasks { count: 0 }]
);
assert_eq!(subagents.list(true)[0].status, SessionStatus::Running);
+14
View File
@@ -337,6 +337,14 @@ pub enum Event {
id: String,
answers: Vec<String>,
},
/// How many background tasks the provider says are alive now.
///
/// State rather than a transcript row: the phone draws it beside the
/// session status. A distinct event keeps the count current while a
/// session is open; `GET /sessions` supplies the opening snapshot.
BackgroundTasks {
count: usize,
},
Status {
state: SessionStatus,
},
@@ -596,6 +604,12 @@ pub type EventSink = mpsc::UnboundedSender<Event>;
/// with live input injects it at the next tool boundary, while a turn-at-a-time
/// dialect queues it for the next child process.
pub trait Driver: Send + Sync {
/// The provider's latest measured number of live background tasks.
/// `None` means it has not reported one, not that the count is zero.
fn background_tasks(&self) -> Option<usize> {
None
}
/// Takes a message, now or once the session is free for it.
///
/// Every driver owes exactly one `MessageTaken` per message, at the moment
+29 -3
View File
@@ -124,6 +124,9 @@ pub struct EchoDriver {
/// says it recovered, and a clear leaves it unmeasured. What is real is
/// which way the numbers move.
context: Arc<AtomicU64>,
/// Live background commands, for the same count a real provider reports.
/// This is the deterministic UI/session-lifecycle rig for that state.
background_tasks: Arc<Mutex<usize>>,
/// This session's subagents -- see `SUBAGENTS.md`. `/subagent` is the
/// test rig for the same registry the claude driver routes real Task
/// calls into.
@@ -500,6 +503,12 @@ impl EchoDriver {
id: id.clone(),
output: format!("Command running in background with ID: {id}"),
});
let background_tasks = Arc::clone(&self.background_tasks);
{
let mut count = background_tasks.lock().unwrap();
*count += 1;
self.emit(Event::BackgroundTasks { count: *count });
}
for word in "Started it; I'll pick this up when it lands.".split_inclusive(' ') {
self.emit(Event::AssistantText {
delta: word.to_string(),
@@ -513,6 +522,11 @@ impl EchoDriver {
let sink = self.sink.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(seconds)).await;
{
let mut count = background_tasks.lock().unwrap();
*count -= 1;
let _ = sink.send(Event::BackgroundTasks { count: *count });
}
let _ = sink.send(Event::ToolUpdate {
id,
output: format!(r#"Background command "{command}" completed (exit code 0)"#),
@@ -526,9 +540,16 @@ impl EchoDriver {
});
tokio::time::sleep(DELTA_DELAY).await;
}
let _ = sink.send(Event::Status {
state: SessionStatus::Idle,
});
{
let count = background_tasks.lock().unwrap();
let _ = sink.send(Event::Status {
state: if *count == 0 {
SessionStatus::Idle
} else {
SessionStatus::Waiting
},
});
}
});
return;
}
@@ -855,6 +876,7 @@ impl EchoDriver {
sink,
pending_questions: Mutex::new(Vec::new()),
context: Arc::new(AtomicU64::new(0)),
background_tasks: Arc::new(Mutex::new(0)),
busy: Arc::new(AtomicBool::new(false)),
queued: Arc::new(Mutex::new(Vec::new())),
session_dir,
@@ -1165,6 +1187,10 @@ fn finish_turn(sink: &EventSink, queued: &Mutex<Vec<Held>>, busy: &AtomicBool) {
}
impl Driver for EchoDriver {
fn background_tasks(&self) -> Option<usize> {
Some(*self.background_tasks.lock().unwrap())
}
fn between_turns(&self) -> bool {
!self.busy.load(Ordering::SeqCst)
}
+6
View File
@@ -272,6 +272,10 @@ pub struct SessionInfo {
pub status: SessionStatus,
pub last_activity: f64,
pub created: f64,
/// The provider's latest measured number of live background tasks.
/// Absent until it reports one; absence is not a measured zero.
#[serde(skip_serializing_if = "Option::is_none")]
pub background_tasks: Option<usize>,
/// How many subagents this session has started, from a directory
/// listing rather than reading each one's status -- see
/// `GET /sessions/{id}/subagents` for that. 0 when it has none, not
@@ -608,6 +612,7 @@ impl LiveSession {
status: *self.shared.status.lock().unwrap(),
last_activity: *self.shared.last_activity.lock().unwrap(),
created: self.meta.created,
background_tasks: self.driver().and_then(|driver| driver.background_tasks()),
subagents: subagent::count(self.dir()),
}
}
@@ -1140,6 +1145,7 @@ impl SessionManager {
status: status_of_unlaunched(&self.data_dir.join(&meta.id)),
last_activity: meta.created,
created: meta.created,
background_tasks: None,
subagents: subagent::count(&self.data_dir.join(&meta.id)),
},
})
+1
View File
@@ -866,6 +866,7 @@ mod tests {
id: "q1".into(),
answers: vec!["Yes".into()],
},
Event::BackgroundTasks { count: 5 },
Event::Status {
state: SessionStatus::Idle,
},