Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions src-tauri/src/acp/background_watch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -829,6 +829,21 @@ impl WatchState {
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
// A later BashOutput (or a fast command that already
// exited on the launch result) carries an exit code.
// That IS done — drop the count now, don't wait for a
// TaskOutput poll or the one-hour keepalive.
let exited = tur.get("exitCode").and_then(|v| v.as_i64()).is_some()
|| tur.get("exit_code").and_then(|v| v.as_i64()).is_some()
|| tur.get("interrupted").and_then(|v| v.as_bool()) == Some(true);
if exited {
if self.tasks.remove(id).is_some() {
self.settled_ids.insert(id.to_string());
tracing::info!(
"[bg-watch] settled task={id} via background shell exit"
);
}
} else {
// Same first-seen rationale as the agent branch above —
// doubly important here since a still-running shell is
// typically observed via REPEATED `BashOutput`-style
Expand All @@ -840,6 +855,7 @@ impl WatchState {
started_at: Instant::now(),
}
});
}
// Deliberately NOT inserted into `current_turn_launched_ids`:
// #870 never holds a turn open for a shell (this
// module's own top-of-file doc comment — "a hold must
Expand Down Expand Up @@ -1312,6 +1328,11 @@ fn is_terminal_task_status(status: &str) -> bool {
matches!(
status,
"completed"
| "complete"
| "success"
| "succeeded"
| "done"
| "exited"
| "failed"
| "canceled"
| "cancelled"
Expand Down Expand Up @@ -1377,6 +1398,14 @@ mod tests {
)
}

/// A later BashOutput (or a short command that already exited) carrying
/// the same `backgroundTaskId` plus an `exitCode`.
fn bash_exited(task_id: &str, exit_code: i64) -> String {
format!(
r#"{{"type":"user","timestamp":"2026-07-07T03:46:40.000Z","uuid":"u-bash-exit-{task_id}","message":{{"role":"user","content":[{{"tool_use_id":"toolu_02b","type":"tool_result","content":"exited {exit_code}"}}]}},"toolUseResult":{{"stdout":"ok","stderr":"","interrupted":false,"backgroundTaskId":"{task_id}","exitCode":{exit_code}}}}}"#
)
}

/// Real-shape `<task-notification>` completion record (string content).
fn notification(task_id: &str, status: &str) -> String {
let inner = format!(
Expand Down Expand Up @@ -2406,6 +2435,41 @@ mod tests {
/// written. That collection must clear the outstanding count (the bug:
/// only `<task-notification>` used to settle, so these stranded for the
/// full keep-alive max-age). A non-terminal poll must NOT clear it.
#[test]
fn bash_exit_code_settles_background_shell() {
let dir = tempfile::tempdir().unwrap();
let path = temp_session(&dir);
write_lines(&path, &[&bash_ack("bash1")]);
let ledger = PromptLedger::shared();
let mut ws = WatchState::with_file_for_test("s1", path.clone());
let (_, outstanding, ..) = unpack(tick_now(&mut ws, &ledger).expect("ack event"));
assert_eq!(outstanding, 1);

write_lines(&path, &[&bash_exited("bash1", 0)]);
let (_, outstanding, settled, _) =
unpack(tick_now(&mut ws, &ledger).expect("settle event"));
assert_eq!(outstanding, 0, "exitCode on BashOutput must clear the count");
assert!(settled.is_empty());
}

#[test]
fn taskoutput_success_status_settles_background_shell() {
let dir = tempfile::tempdir().unwrap();
let path = temp_session(&dir);
write_lines(&path, &[&bash_ack("bash1")]);
let ledger = PromptLedger::shared();
let mut ws = WatchState::with_file_for_test("s1", path.clone());
let _ = tick_now(&mut ws, &ledger);

write_lines(&path, &[&taskoutput_result("bash1", "success")]);
let (_, outstanding, ..) =
unpack(tick_now(&mut ws, &ledger).expect("settle event"));
assert_eq!(
outstanding, 0,
"TaskOutput status=success must clear the count"
);
}

#[test]
fn taskoutput_terminal_status_settles_background_shell() {
let dir = tempfile::tempdir().unwrap();
Expand Down
158 changes: 146 additions & 12 deletions src-tauri/src/acp/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9419,6 +9419,11 @@ struct CodeBuddyLiveState {
/// the case whose result would otherwise never reach the card live; a
/// blocking spawn's own completion frame carries the output instead.
grok_settled_spawn_ids: HashSet<String>,
/// Grok `run_terminal_command` / similar tasks that were moved to the
/// background (`task_backgrounded`). Removed on `task_completed` so the
/// chip leaves as soon as grok says the process exited — same automatic
/// settle as Claude's TaskOutput / exitCode path.
grok_bg_task_ids: HashSet<String>,
/// Spawn calls announced in the CURRENT turn — the only ones whose
/// `subagent_progress` ticks may emit a live `ToolCallUpdate`. Cleared at
/// every turn start (with the pending queue): a tick for a PRIOR turn's
Expand Down Expand Up @@ -9459,6 +9464,18 @@ struct GrokPendingSpawn {
/// `spawn_subagent` launcher (`_meta["x.ai/tool"].name`). Present on the very
/// first frame (verified against real captures), unlike `subagent_type` which
/// rides `rawInput`. Gated on Grok so the namespaced key can't affect others.
/// Background children (spawned sub-agents whose launch call already settled)
/// plus backgrounded shell tasks. One number drives the chip for every Grok
/// source — the bar leaves when this hits zero.
fn grok_outstanding_count(cb_state: &CodeBuddyLiveState) -> u32 {
let children = cb_state
.grok_subagent_to_call
.values()
.filter(|call_id| cb_state.grok_settled_spawn_ids.contains(*call_id))
.count();
(children + cb_state.grok_bg_task_ids.len()) as u32
}

fn grok_meta_marks_spawn_subagent(
agent_type: AgentType,
meta: Option<&serde_json::Map<String, serde_json::Value>>,
Expand Down Expand Up @@ -9805,6 +9822,90 @@ fn map_grok_ext_notification(
}
}

/// Grok `run_terminal_command` (and similar) moved to the background.
/// `task_backgrounded` is the only live pairing of `tool_call_id` ↔ `task_id`;
/// `task_completed` is the done signal (exit code on `task_snapshot`). The
/// history parser deliberately does not rewrite the launch card from that
/// snapshot; the chip still has to drop the moment grok says the process exited.
fn map_grok_background_task_notification(
notification: &UntypedMessage,
agent_type: AgentType,
cb_state: &mut CodeBuddyLiveState,
) -> Option<AcpEvent> {
if !matches!(agent_type, AgentType::Grok) {
return None;
}
if !GROK_EXT_UPDATE_METHODS.contains(&notification.method()) {
return None;
}
let params = notification.params();
let update = params.get("update")?;
let session_id = params.get("sessionId").and_then(|v| v.as_str())?;
match update.get("sessionUpdate").and_then(|v| v.as_str())? {
"task_backgrounded" => {
let task_id = update
.get("task_id")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())?;
cb_state.grok_bg_task_ids.insert(task_id.to_string());
Some(AcpEvent::BackgroundActivity {
session_id: session_id.to_string(),
turns: Vec::new(),
outstanding: grok_outstanding_count(cb_state),
settled: Vec::new(),
watermark: 0,
})
}
"task_completed" => {
let snapshot = update.get("task_snapshot");
let task_id = snapshot
.and_then(|s| s.get("task_id"))
.or_else(|| update.get("task_id"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())?;
cb_state.grok_bg_task_ids.remove(task_id);
let status = snapshot
.and_then(|s| s.get("exit_code"))
.and_then(|v| v.as_i64())
.map(|code| {
if code == 0 {
"completed".to_string()
} else {
"failed".to_string()
}
})
.unwrap_or_else(|| "completed".to_string());
Some(AcpEvent::BackgroundActivity {
session_id: session_id.to_string(),
turns: Vec::new(),
outstanding: grok_outstanding_count(cb_state),
settled: vec![crate::acp::types::BackgroundSettledInfo {
task_id: task_id.to_string(),
status,
summary: None,
tool_use_id: update
.get("tool_call_id")
.and_then(|v| v.as_str())
.map(str::to_string),
result: snapshot
.and_then(|s| s.get("output"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| {
crate::parsers::truncate_str(
s,
crate::parsers::claude::BACKGROUND_RESULT_MAX_CHARS,
)
}),
wire_visible: true,
}],
watermark: 0,
})
}
_ => None,
}
}

/// Map grok's sub-agent lifecycle notifications (`_x.ai/session/update` with
/// `sessionUpdate: subagent_spawned | subagent_progress | subagent_finished`)
/// onto live events for the launching `spawn_subagent` Agent card. STATEFUL —
Expand Down Expand Up @@ -9866,13 +9967,7 @@ fn map_grok_subagent_notification_inner(
let subagent_id = update.get("subagent_id").and_then(|v| v.as_str())?;
// `outstanding` = paired subagents still running whose launch call already
// settled — i.e. background children codeg would otherwise sweep as idle.
let outstanding = |cb_state: &CodeBuddyLiveState| {
cb_state
.grok_subagent_to_call
.values()
.filter(|call_id| cb_state.grok_settled_spawn_ids.contains(*call_id))
.count() as u32
};
let outstanding = |cb_state: &CodeBuddyLiveState| grok_outstanding_count(cb_state);
match update.get("sessionUpdate").and_then(|v| v.as_str())? {
"subagent_spawned" => {
// First pending entry whose captured launch `(description,
Expand Down Expand Up @@ -10191,6 +10286,10 @@ async fn maybe_emit_ext_notification(
for event in grok_subagent_events {
emit_with_state(state, emitter, event).await;
}
} else if let Some(event) =
map_grok_background_task_notification(&notification, agent_type, cb_state)
{
emit_with_state(state, emitter, event).await;
} else if let Some(event) = map_claude_sdk_ext_notification(&notification)
.or_else(|| map_grok_ext_notification(&notification, agent_type))
{
Expand Down Expand Up @@ -10600,11 +10699,7 @@ async fn emit_conversation_update(
{
let session_id = state.read().await.external_id.clone();
if let Some(session_id) = session_id {
let outstanding = cb_state
.grok_subagent_to_call
.values()
.filter(|call| cb_state.grok_settled_spawn_ids.contains(*call))
.count() as u32;
let outstanding = grok_outstanding_count(cb_state);
emit_with_state(
state,
emitter,
Expand Down Expand Up @@ -13047,6 +13142,45 @@ mod tests {
.is_empty());
}

#[test]
fn map_grok_background_task_leaves_the_chip_when_the_process_exits() {
let mut cb = CodeBuddyLiveState::default();
let started = grok_subagent_notif(serde_json::json!({
"sessionUpdate": "task_backgrounded",
"tool_call_id": "call-1",
"task_id": "term_x",
"command": "pnpm build"
}));
match map_grok_background_task_notification(&started, AgentType::Grok, &mut cb) {
Some(AcpEvent::BackgroundActivity { outstanding, .. }) => {
assert_eq!(outstanding, 1);
}
other => panic!("expected backgrounded activity, got {other:?}"),
}

let finished = grok_subagent_notif(serde_json::json!({
"sessionUpdate": "task_completed",
"task_snapshot": {
"task_id": "term_x",
"exit_code": 0,
"output": "ok"
}
}));
match map_grok_background_task_notification(&finished, AgentType::Grok, &mut cb) {
Some(AcpEvent::BackgroundActivity {
outstanding,
settled,
..
}) => {
assert_eq!(outstanding, 0, "task_completed must clear the count");
assert_eq!(settled.len(), 1);
assert_eq!(settled[0].task_id, "term_x");
assert_eq!(settled[0].status, "completed");
}
other => panic!("expected completed activity, got {other:?}"),
}
}

/// A BLOCKING spawn (call not yet settled when the child finishes) must NOT
/// emit a settle — its own completion frame carries the output; a duplicate
/// marker would double-render it. Non-grok agents never route at all.
Expand Down
26 changes: 25 additions & 1 deletion src-tauri/src/acp/session_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1570,7 +1570,16 @@ impl SessionState {
pending_user_message: self.pending_user_message.clone(),
active_delegations: self.active_delegations.values().cloned().collect(),
feedback: self.feedback.clone(),
background_outstanding: self.background_outstanding,
background_outstanding: if self.has_active_background_work(Utc::now()) {
self.background_outstanding
} else {
0
},
background_activity_at: if self.has_active_background_work(Utc::now()) {
self.background_activity_at
} else {
None
},
feedback_tool_available: self.feedback_tool_available,
native_steering_available: self.native_steering_available,
modes: self.modes.clone(),
Expand Down Expand Up @@ -1659,6 +1668,12 @@ pub struct LiveSessionSnapshot {
/// common no-background case keeps the wire shape byte-identical.
#[serde(default, skip_serializing_if = "u32_is_zero")]
pub background_outstanding: u32,
/// Instant of the last `BackgroundActivity` event. Lets a reconnecting
/// client hide a hydrated count whose keepalive heartbeat is already dead
/// (Grok has no watcher ticker; Claude emits `0` itself after max-age).
/// Omitted when outstanding is zero.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background_activity_at: Option<DateTime<Utc>>,
/// Whether this agent has the `check_user_feedback` tool (see
/// `SessionState.feedback_tool_available`). `#[serde(default)]` so older
/// payloads deserialize to `false`; the frontend gates the feedback bar on
Expand Down Expand Up @@ -2144,6 +2159,15 @@ mod tests {
watermark: 0,
});
assert_eq!(s.to_snapshot().background_outstanding, 3);
assert!(s.to_snapshot().background_activity_at.is_some());

// A count whose heartbeat is already past max-age must not come back
// to life on attach — the chip would otherwise stay up forever.
s.background_activity_at = Some(
Utc::now() - background_keepalive_max_age() - chrono::Duration::seconds(1),
);
assert_eq!(s.to_snapshot().background_outstanding, 0);
assert!(s.to_snapshot().background_activity_at.is_none());
let json = serde_json::to_value(s.to_snapshot()).unwrap();
assert_eq!(
json.get("background_outstanding").and_then(|v| v.as_u64()),
Expand Down
Loading
Loading