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
+157 -26

No files matched your search

+4 -1
View File
@@ -264,7 +264,10 @@ written, and the fold uses that same predicate to decide a reply is settled.
- **Claude's background-task level is authority; two edge sources are the - **Claude's background-task level is authority; two edge sources are the
fallback.** Since Claude Code 2.1.261, fallback.** Since Claude Code 2.1.261,
`background_tasks_changed { tasks: [...] }` replaces the live set and repairs `background_tasks_changed { tasks: [...] }` replaces the live set and repairs
a missed ending edge. An adopted CLI is sent a repeated `initialize` to ask a missed ending edge. Its array size is also the measured `backgroundTasks`
count exposed on the session row and event stream; the phone draws a nonzero
count beside the status rather than deriving one from `waiting` or from the
subagent directory. An adopted CLI is sent a repeated `initialize` to ask
for the current set. Reconcile only between turns or at a result boundary: for the current set. Reconcile only between turns or at a result boundary:
a foreground agent is legitimately absent from a background-only snapshot. a foreground agent is legitimately absent from a background-only snapshot.
Older CLIs still need both edge sources: `open_tasks` knows about a Older CLIs still need both edge sources: `open_tasks` knows about a
+7 -2
View File
@@ -858,7 +858,9 @@ the reader was not asking after, and one of them turned out to be a whole shell
command drawn as centred prose, because its words came from somewhere with no command drawn as centred prose, because its words came from somewhere with no
reason to keep them short. The parent's transcript gets a row for a message a reason to keep them short. The parent's transcript gets a row for a message a
subagent genuinely *sends* it, which arrives by the peer path and already has subagent genuinely *sends* it, which arrives by the peer path and already has
one. one. The live provider-reported count beside the parent session's status is
deliberately the only background-task UI until there is a design for inspecting
them.
**The report goes to whichever record is the only one of it**, and the two cases **The report goes to whichever record is the only one of it**, and the two cases
are different places. A subagent has a transcript of its own, and its closing are different places. A subagent has a transcript of its own, and its closing
@@ -874,7 +876,10 @@ the ones a stale "running in background" reads worst on.
(2026-09-15). Claude Code 2.1.261 added (2026-09-15). Claude Code 2.1.261 added
`background_tasks_changed { tasks: [...] }` with replace semantics expressly so `background_tasks_changed { tasks: [...] }` with replace semantics expressly so
a missed bookend cannot wedge a running indicator. Its ids are not correlated a missed bookend cannot wedge a running indicator. Its ids are not correlated
with the edge stream; this side uses the authoritative empty/nonempty level. with the edge stream; this side uses the authoritative empty/nonempty level and
its measured size. That size is exposed as `backgroundTasks` in the session row
and event stream, and is drawn beside the status; background tasks do not become
subagent cards.
The driver sends a repeated `initialize` when it adopts a CLI, which prompts a The driver sends a repeated `initialize` when it adopts a CLI, which prompts a
full snapshot without restarting the conversation. A parent already recorded as idle or waiting can full snapshot without restarting the conversation. A parent already recorded as idle or waiting can
apply it immediately; one adopted mid-turn waits for the result boundary, apply it immediately; one adopted mid-turn waits for the result boundary,
+5 -3
View File
@@ -89,9 +89,11 @@ transcript is still being written to and its process is the session's to stop.
Since Claude Code 2.1.261, `background_tasks_changed { tasks: [...] }` is Since Claude Code 2.1.261, `background_tasks_changed { tasks: [...] }` is
the authoritative level beside those edges: its set replaces the previous the authoritative level beside those edges: its set replaces the previous
set, so a missed terminal edge cannot leave a subagent running forever. Its set, so a missed terminal edge cannot leave a subagent running forever. Its
ids are deliberately not correlated with the edge stream; the useful claim ids are deliberately not correlated with the edge stream; the useful claims
here is whether the set is empty. The edges still carry mapping, outcome here are whether the set is empty and its measured size. The session API and
and closing summary. On adoption the driver sends a repeated `initialize`, stream expose that size as `backgroundTasks`, which the phone draws beside
the status without pretending those tasks are subagents. The edges still
carry mapping, outcome and closing summary. On adoption the driver sends a repeated `initialize`,
which makes a current CLI send the full set; an older CLI accepts it and sends no level, which makes a current CLI send the full set; an older CLI accepts it and sends no level,
leaving the edge-based path unchanged. A snapshot is reconciled immediately leaving the edge-based path unchanged. A snapshot is reconciled immediately
when the persisted parent status proves it is between turns, and otherwise when the persisted parent status proves it is between turns, and otherwise
+4
View File
@@ -5,6 +5,10 @@ one in place when it turns out to need a decision.
## App — transcript ## App — transcript
- [ ] Decide how running background tasks can be inspected. For now the session
status shows only the provider-reported count; command details stay in
their existing tool cards and must not become subagent cards.
- [ ] Messages received from other agents are inconsistent — sometimes they - [ ] Messages received from other agents are inconsistent — sometimes they
appear, sometimes they don't. **Needs a rig.** Read the code rather than appear, sometimes they don't. **Needs a rig.** Read the code rather than
measured: a live Claude session only learns of a peer message from the measured: a live Claude session only learns of a peer message from the
@@ -225,6 +225,8 @@ data class SessionSummary(
val usageProvider: String?, val usageProvider: String?,
val status: String, val status: String,
val lastActivity: Double, val lastActivity: Double,
/** Latest measured number of live background tasks; zero also covers older servers. */
val backgroundTasks: Int,
/** /**
* How many subagents this session has, however their own status now reads. * How many subagents this session has, however their own status now reads.
* *
@@ -263,6 +265,7 @@ private fun parseSession(session: JSONObject) =
usageProvider = session.optString("usageProvider").ifEmpty { null }, usageProvider = session.optString("usageProvider").ifEmpty { null },
status = session.getString("status"), status = session.getString("status"),
lastActivity = session.getDouble("lastActivity"), lastActivity = session.getDouble("lastActivity"),
backgroundTasks = session.optInt("backgroundTasks", 0),
subagents = session.optInt("subagents", 0), subagents = session.optInt("subagents", 0),
) )
@@ -140,6 +140,9 @@ sealed class SessionEvent {
/** The same command, handed to the session. */ /** The same command, handed to the session. */
data class CommandSent(val id: String, val text: String) : SessionEvent() data class CommandSent(val id: String, val text: String) : SessionEvent()
/** Provider-reported number of background tasks alive now. */
data class BackgroundTasks(val count: Int) : SessionEvent()
data class Status(val state: String) : SessionEvent() data class Status(val state: String) : SessionEvent()
/** /**
@@ -283,6 +286,7 @@ fun parseSeqEvent(json: String): SeqEvent {
"commandQueued" -> "commandQueued" ->
SessionEvent.CommandQueued(body.getString("id"), body.getString("text")) SessionEvent.CommandQueued(body.getString("id"), body.getString("text"))
"commandSent" -> SessionEvent.CommandSent(body.getString("id"), body.getString("text")) "commandSent" -> SessionEvent.CommandSent(body.getString("id"), body.getString("text"))
"backgroundTasks" -> SessionEvent.BackgroundTasks(body.getInt("count"))
"status" -> SessionEvent.Status(body.getString("state")) "status" -> SessionEvent.Status(body.getString("state"))
"settings" -> "settings" ->
SessionEvent.Settings( SessionEvent.Settings(
@@ -554,6 +554,14 @@ private fun SessionCard(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
StatusText(session.status) StatusText(session.status)
if (session.backgroundTasks > 0) {
Text(
backgroundTaskLabel(session.backgroundTasks),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp),
)
}
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Row(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) {
@@ -256,6 +256,8 @@ fun SessionScreen(
val topEdgeHeld = remember { TopEdgeHold() } val topEdgeHeld = remember { TopEdgeHold() }
var items by remember { mutableStateOf(listOf<TranscriptItem>()) } var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
var status by remember { mutableStateOf(subagent?.status ?: summary.status) } var status by remember { mutableStateOf(subagent?.status ?: summary.status) }
var backgroundTasks by
remember(address) { mutableIntStateOf(if (isSubagent) 0 else summary.backgroundTasks) }
// Seeded from the row this screen was opened from, so a conversation already under way says how // Seeded from the row this screen was opened from, so a conversation already under way says how
// much it is holding before any turn happens here. Null is "nobody has measured it", which is a // much it is holding before any turn happens here. Null is "nobody has measured it", which is a
// different answer from an empty context and is drawn differently. // different answer from an empty context and is drawn differently.
@@ -538,6 +540,9 @@ fun SessionScreen(
} }
status = event.state status = event.state
} }
if (event is SessionEvent.BackgroundTasks && !isSubagent) {
backgroundTasks = event.count
}
if (!isSubagent) loginOpen = authenticationPromptAfter(loginOpen, event) if (!isSubagent) loginOpen = authenticationPromptAfter(loginOpen, event)
// In order, always: one late event recorded ahead of the backlog would fold a // In order, always: one late event recorded ahead of the backlog would fold a
// streamed delta into whatever row happened to be last by then. // streamed delta into whatever row happened to be last by then.
@@ -1842,6 +1847,7 @@ fun SessionScreen(
status = status, status = status,
compactingFor = compactingFor, compactingFor = compactingFor,
contextTokens = contextTokens, contextTokens = contextTokens,
backgroundTasks = backgroundTasks,
subagent = isSubagent, subagent = isSubagent,
) )
@@ -2362,6 +2368,8 @@ private fun SessionStatusRow(
compactingFor: Long?, compactingFor: Long?,
/** Context the session is holding, or null where nothing has measured it. */ /** Context the session is holding, or null where nothing has measured it. */
contextTokens: Long?, contextTokens: Long?,
/** Provider-reported live background work; zero is deliberately not drawn. */
backgroundTasks: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
/** /**
* Whether this row is for a subagent rather than a session, which changes only one word: * Whether this row is for a subagent rather than a session, which changes only one word:
@@ -2395,6 +2403,14 @@ private fun SessionStatusRow(
// its own contrast, since the surface under it will not change to rescue it. // its own contrast, since the surface under it will not change to rescue it.
color = commandColor, color = commandColor,
) )
if (backgroundTasks > 0) {
Text(
backgroundTaskLabel(backgroundTasks),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp),
)
}
LinearProgressIndicator( LinearProgressIndicator(
color = commandColor, color = commandColor,
trackColor = MaterialTheme.colorScheme.surfaceContainerHigh, trackColor = MaterialTheme.colorScheme.surfaceContainerHigh,
@@ -2415,6 +2431,14 @@ private fun SessionStatusRow(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp), modifier = Modifier.padding(start = 8.dp),
) )
if (backgroundTasks > 0) {
Text(
backgroundTaskLabel(backgroundTasks),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp),
)
}
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
} }
// Every remaining state says which one it is, including the quiet one. The row used to // Every remaining state says which one it is, including the quiet one. The row used to
@@ -2423,13 +2447,22 @@ private fun SessionStatusRow(
// showed nothing at all. The words and the colour are `sessionStatusWord`'s and // showed nothing at all. The words and the colour are `sessionStatusWord`'s and
// `sessionStatusColour`'s, shared with the session list so one state is not called two // `sessionStatusColour`'s, shared with the session list so one state is not called two
// things -- or drawn two colours -- depending which screen you are on. // things -- or drawn two colours -- depending which screen you are on.
else -> else -> {
Text( Text(
sessionStatusWord(status, subagent), sessionStatusWord(status, subagent),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = sessionStatusColour(status), color = sessionStatusColour(status),
modifier = Modifier.weight(1f),
) )
if (backgroundTasks > 0) {
Text(
backgroundTaskLabel(backgroundTasks),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp),
)
}
Spacer(Modifier.weight(1f))
}
} }
// How full the session is, which is the number a reader is asking about -- how much room is // How full the session is, which is the number a reader is asking about -- how much room is
// left before the next compaction -- rather than what has been spent getting here. // left before the next compaction -- rather than what has been spent getting here.
@@ -38,6 +38,8 @@ fun sessionStatusWord(status: String, subagent: Boolean = false): String =
else -> status else -> status
} }
fun backgroundTaskLabel(count: Int): String = "$count bg ${if (count == 1) "task" else "tasks"}"
/** /**
* The colour that goes with [sessionStatusWord]: the accent is spent on the states that are about * The colour that goes with [sessionStatusWord]: the accent is spent on the states that are about
* to do something or want something, and every quiet one shares the muted colour. * to do something or want something, and every quiet one shares the muted colour.
@@ -517,6 +517,7 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
// nothing it belongs above. // nothing it belongs above.
is SessionEvent.MessageDropped -> items is SessionEvent.MessageDropped -> items
is SessionEvent.Settings -> items is SessionEvent.Settings -> items
is SessionEvent.BackgroundTasks -> items
is SessionEvent.Status -> settleReply(items, event.state) is SessionEvent.Status -> settleReply(items, event.state)
is SessionEvent.AuthenticationRequired -> is SessionEvent.AuthenticationRequired ->
items + TranscriptItem.ErrorMsg(entry.seq, event.message) items + TranscriptItem.ErrorMsg(entry.seq, event.message)
+4
View File
@@ -510,6 +510,10 @@ impl ClaudeDriver {
} }
impl Driver for 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>) { fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>) {
let mut content = Vec::new(); let mut content = Vec::new();
// An image goes into the message itself; the model looks at it. Any // An image goes into the message itself; the model looks at it. Any
+32 -17
View File
@@ -136,8 +136,9 @@ pub(super) struct Translator {
/// Claude's level signal for background work, when this CLI is new enough /// 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 /// 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 /// replaces the old one, so a missed ending edge cannot leave work open
/// forever. See [`Translator::translate_background_tasks`]. /// forever. The count is also shown beside the session's status. See
background_tasks: Option<bool>, /// [`Translator::translate_background_tasks`].
background_tasks: Option<usize>,
/// Tasks the level signal closed before their ordinary notification /// Tasks the level signal closed before their ordinary notification
/// arrived. That notification still owns the useful summary, so it gets /// arrived. That notification still owns the useful summary, so it gets
/// one chance to update the transcript or tool card after the status was /// 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(); let was_outstanding = self.work_outstanding();
// The CLI explicitly says not to correlate these ids with its edge // The CLI explicitly says not to correlate these ids with its edge
// stream. Their useful claim here is the level: empty or not. // stream. Their useful claims here are the level and its exact size.
self.background_tasks = Some(!tasks.is_empty()); 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 // During a turn the snapshot omits a foreground task, so wait for the
// result boundary before using it to close anything. Between turns, // result boundary before using it to close anything. Between turns,
// every task that can still be alive is background work and the level // every task that can still be alive is background work and the level
// can repair a missed notification immediately. // can repair a missed notification immediately.
if !self.settled || self.in_turn { 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(); let is_outstanding = self.work_outstanding();
if was_outstanding != is_outstanding { if was_outstanding != is_outstanding {
events.push(Event::Status { events.push(Event::Status {
@@ -677,7 +679,7 @@ impl Translator {
/// foreground task can remain. Returns updates for background commands; /// foreground task can remain. Returns updates for background commands;
/// subagents carry the same correction in their own status transcript. /// subagents carry the same correction in their own status transcript.
fn reconcile_background_tasks(&mut self) -> Vec<Event> { 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(); return Vec::new();
}; };
let mut events = Vec::new(); let mut events = Vec::new();
@@ -717,11 +719,17 @@ impl Translator {
/// `session_running` is true by construction: this is only ever asked /// `session_running` is true by construction: this is only ever asked
/// while translating a line the session's process just wrote. /// while translating a line the session's process just wrote.
fn work_outstanding(&self) -> bool { 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.open_tasks.is_empty()
|| self.subagents.any_open(true) || 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. /// 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 /// 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)); let mut translator = Translator::new(dir.path().to_path_buf(), Arc::clone(&subagents));
translator.mark_settled(); translator.mark_settled();
assert!( assert_eq!(
translate_lines( translate_lines(
&mut translator, &mut translator,
&[r#"{"type":"system","subtype":"background_tasks_changed","tasks":["toolu_stale"]}"#], &[
) r#"{"type":"system","subtype":"background_tasks_changed","tasks":["toolu_stale","command-2","command-3","command-4","command-5"]}"#
.is_empty() ],
),
vec![Event::BackgroundTasks { count: 5 }]
); );
assert_eq!(translator.background_task_count(), Some(5));
assert_eq!(subagents.list(true)[0].status, SessionStatus::Running); assert_eq!(subagents.list(true)[0].status, SessionStatus::Running);
assert_eq!( assert_eq!(
translate_lines( translate_lines(
&mut translator, &mut translator,
&[r#"{"type":"system","subtype":"background_tasks_changed","tasks":[]}"#], &[r#"{"type":"system","subtype":"background_tasks_changed","tasks":[]}"#],
), ),
vec![Event::Status { vec![
Event::BackgroundTasks { count: 0 },
Event::Status {
state: SessionStatus::Idle state: SessionStatus::Idle
}] }
]
); );
assert_eq!(translator.background_task_count(), Some(0));
assert_eq!(subagents.list(true)[0].status, SessionStatus::Exited); 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)); let mut translator = Translator::new(dir.path().to_path_buf(), Arc::clone(&subagents));
translator.mark_running(); translator.mark_running();
assert!( assert_eq!(
translate_lines( translate_lines(
&mut translator, &mut translator,
&[r#"{"type":"system","subtype":"background_tasks_changed","tasks":[]}"#], &[r#"{"type":"system","subtype":"background_tasks_changed","tasks":[]}"#],
) ),
.is_empty() vec![Event::BackgroundTasks { count: 0 }]
); );
assert_eq!(subagents.list(true)[0].status, SessionStatus::Running); assert_eq!(subagents.list(true)[0].status, SessionStatus::Running);
+14
View File
@@ -337,6 +337,14 @@ pub enum Event {
id: String, id: String,
answers: Vec<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 { Status {
state: SessionStatus, 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 /// with live input injects it at the next tool boundary, while a turn-at-a-time
/// dialect queues it for the next child process. /// dialect queues it for the next child process.
pub trait Driver: Send + Sync { 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. /// Takes a message, now or once the session is free for it.
/// ///
/// Every driver owes exactly one `MessageTaken` per message, at the moment /// Every driver owes exactly one `MessageTaken` per message, at the moment
+27 -1
View File
@@ -124,6 +124,9 @@ pub struct EchoDriver {
/// says it recovered, and a clear leaves it unmeasured. What is real is /// says it recovered, and a clear leaves it unmeasured. What is real is
/// which way the numbers move. /// which way the numbers move.
context: Arc<AtomicU64>, 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 /// This session's subagents -- see `SUBAGENTS.md`. `/subagent` is the
/// test rig for the same registry the claude driver routes real Task /// test rig for the same registry the claude driver routes real Task
/// calls into. /// calls into.
@@ -500,6 +503,12 @@ impl EchoDriver {
id: id.clone(), id: id.clone(),
output: format!("Command running in background with ID: {id}"), 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(' ') { for word in "Started it; I'll pick this up when it lands.".split_inclusive(' ') {
self.emit(Event::AssistantText { self.emit(Event::AssistantText {
delta: word.to_string(), delta: word.to_string(),
@@ -513,6 +522,11 @@ impl EchoDriver {
let sink = self.sink.clone(); let sink = self.sink.clone();
tokio::spawn(async move { tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(seconds)).await; 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 { let _ = sink.send(Event::ToolUpdate {
id, id,
output: format!(r#"Background command "{command}" completed (exit code 0)"#), output: format!(r#"Background command "{command}" completed (exit code 0)"#),
@@ -526,9 +540,16 @@ impl EchoDriver {
}); });
tokio::time::sleep(DELTA_DELAY).await; tokio::time::sleep(DELTA_DELAY).await;
} }
{
let count = background_tasks.lock().unwrap();
let _ = sink.send(Event::Status { let _ = sink.send(Event::Status {
state: SessionStatus::Idle, state: if *count == 0 {
SessionStatus::Idle
} else {
SessionStatus::Waiting
},
}); });
}
}); });
return; return;
} }
@@ -855,6 +876,7 @@ impl EchoDriver {
sink, sink,
pending_questions: Mutex::new(Vec::new()), pending_questions: Mutex::new(Vec::new()),
context: Arc::new(AtomicU64::new(0)), context: Arc::new(AtomicU64::new(0)),
background_tasks: Arc::new(Mutex::new(0)),
busy: Arc::new(AtomicBool::new(false)), busy: Arc::new(AtomicBool::new(false)),
queued: Arc::new(Mutex::new(Vec::new())), queued: Arc::new(Mutex::new(Vec::new())),
session_dir, session_dir,
@@ -1165,6 +1187,10 @@ fn finish_turn(sink: &EventSink, queued: &Mutex<Vec<Held>>, busy: &AtomicBool) {
} }
impl Driver for EchoDriver { impl Driver for EchoDriver {
fn background_tasks(&self) -> Option<usize> {
Some(*self.background_tasks.lock().unwrap())
}
fn between_turns(&self) -> bool { fn between_turns(&self) -> bool {
!self.busy.load(Ordering::SeqCst) !self.busy.load(Ordering::SeqCst)
} }
+6
View File
@@ -272,6 +272,10 @@ pub struct SessionInfo {
pub status: SessionStatus, pub status: SessionStatus,
pub last_activity: f64, pub last_activity: f64,
pub created: 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 /// How many subagents this session has started, from a directory
/// listing rather than reading each one's status -- see /// listing rather than reading each one's status -- see
/// `GET /sessions/{id}/subagents` for that. 0 when it has none, not /// `GET /sessions/{id}/subagents` for that. 0 when it has none, not
@@ -608,6 +612,7 @@ impl LiveSession {
status: *self.shared.status.lock().unwrap(), status: *self.shared.status.lock().unwrap(),
last_activity: *self.shared.last_activity.lock().unwrap(), last_activity: *self.shared.last_activity.lock().unwrap(),
created: self.meta.created, created: self.meta.created,
background_tasks: self.driver().and_then(|driver| driver.background_tasks()),
subagents: subagent::count(self.dir()), subagents: subagent::count(self.dir()),
} }
} }
@@ -1140,6 +1145,7 @@ impl SessionManager {
status: status_of_unlaunched(&self.data_dir.join(&meta.id)), status: status_of_unlaunched(&self.data_dir.join(&meta.id)),
last_activity: meta.created, last_activity: meta.created,
created: meta.created, created: meta.created,
background_tasks: None,
subagents: subagent::count(&self.data_dir.join(&meta.id)), subagents: subagent::count(&self.data_dir.join(&meta.id)),
}, },
}) })
+1
View File
@@ -866,6 +866,7 @@ mod tests {
id: "q1".into(), id: "q1".into(),
answers: vec!["Yes".into()], answers: vec!["Yes".into()],
}, },
Event::BackgroundTasks { count: 5 },
Event::Status { Event::Status {
state: SessionStatus::Idle, state: SessionStatus::Idle,
}, },