Show live background task counts
This commit is contained in:
1 parent
9fd21af4e8
commit
8262ceb786
16 files changed
+160
-29
No files matched your search
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -866,6 +866,7 @@ mod tests {
|
||||
id: "q1".into(),
|
||||
answers: vec!["Yes".into()],
|
||||
},
|
||||
Event::BackgroundTasks { count: 5 },
|
||||
Event::Status {
|
||||
state: SessionStatus::Idle,
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user