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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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![
|
||||||
state: SessionStatus::Idle
|
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);
|
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);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 _ = 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;
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user