diff --git a/src-tauri/src/acp/background_watch.rs b/src-tauri/src/acp/background_watch.rs index 69a6707a8..810e8b6e3 100644 --- a/src-tauri/src/acp/background_watch.rs +++ b/src-tauri/src/acp/background_watch.rs @@ -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 @@ -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 @@ -1312,6 +1328,11 @@ fn is_terminal_task_status(status: &str) -> bool { matches!( status, "completed" + | "complete" + | "success" + | "succeeded" + | "done" + | "exited" | "failed" | "canceled" | "cancelled" @@ -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 `` completion record (string content). fn notification(task_id: &str, status: &str) -> String { let inner = format!( @@ -2406,6 +2435,41 @@ mod tests { /// written. That collection must clear the outstanding count (the bug: /// only `` 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(); diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 94123e900..6d9ea5a5c 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -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, + /// 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, /// 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 @@ -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>, @@ -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 { + if !matches!(agent_type, AgentType::Grok) { + return None; + } + if !GROK_EXT_UPDATE_METHODS.contains(¬ification.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 — @@ -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, @@ -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(¬ification, agent_type, cb_state) + { + emit_with_state(state, emitter, event).await; } else if let Some(event) = map_claude_sdk_ext_notification(¬ification) .or_else(|| map_grok_ext_notification(¬ification, agent_type)) { @@ -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, @@ -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. diff --git a/src-tauri/src/acp/session_state.rs b/src-tauri/src/acp/session_state.rs index 3cb63facb..6ec063b02 100644 --- a/src-tauri/src/acp/session_state.rs +++ b/src-tauri/src/acp/session_state.rs @@ -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(), @@ -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>, /// 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 @@ -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()), diff --git a/src/components/chat/background-tasks-chip.tsx b/src/components/chat/background-tasks-chip.tsx index 6810a2186..e5e5a7430 100644 --- a/src/components/chat/background-tasks-chip.tsx +++ b/src/components/chat/background-tasks-chip.tsx @@ -4,6 +4,10 @@ import { useEffect, useState } from "react" import { useTranslations } from "next-intl" import { Loader2 } from "lucide-react" import { useConnection } from "@/hooks/use-connection" +import { + BACKGROUND_KEEPALIVE_MAX_MS, + visibleBackgroundOutstanding, +} from "@/lib/background-activity" /** * How long the "syncing results" state stays visible after a settlement with @@ -29,13 +33,17 @@ const SETTLE_SYNC_DISPLAY_MS = 30_000 */ export function BackgroundTasksChip({ contextKey }: { contextKey: string }) { const t = useTranslations("Folder.chat.backgroundTasks") - const { backgroundOutstanding, backgroundSettleSyncingSince } = - useConnection(contextKey) + const { + backgroundOutstanding, + backgroundActivityAt, + backgroundSettleSyncingSince, + } = useConnection(contextKey) // Which arm timestamp has display-expired. Tied to the specific value so a // re-arm (another settlement → fresh timestamp) un-expires automatically, // and render stays pure (Date.now() only runs inside the effect). const [expiredFor, setExpiredFor] = useState(null) + const [nowMs, setNowMs] = useState(() => Date.now()) useEffect(() => { if (backgroundSettleSyncingSince == null) return const remaining = @@ -49,12 +57,29 @@ export function BackgroundTasksChip({ contextKey }: { contextKey: string }) { return () => clearTimeout(timer) }, [backgroundSettleSyncingSince]) + useEffect(() => { + if (backgroundOutstanding <= 0 || backgroundActivityAt == null) return + const remaining = + BACKGROUND_KEEPALIVE_MAX_MS - (Date.now() - backgroundActivityAt) + const timer = setTimeout( + () => setNowMs(Date.now()), + Math.max(0, remaining) + 50 + ) + return () => clearTimeout(timer) + }, [backgroundOutstanding, backgroundActivityAt]) + + const visibleOutstanding = visibleBackgroundOutstanding( + backgroundOutstanding, + backgroundActivityAt, + nowMs + ) + const showSyncing = - backgroundOutstanding <= 0 && + visibleOutstanding <= 0 && backgroundSettleSyncingSince != null && expiredFor !== backgroundSettleSyncingSince - if (backgroundOutstanding <= 0 && !showSyncing) return null + if (visibleOutstanding <= 0 && !showSyncing) return null return (
@@ -63,8 +88,8 @@ export function BackgroundTasksChip({ contextKey }: { contextKey: string }) { only "still working" signal for Reduce Motion users. */} - {backgroundOutstanding > 0 - ? t("running", { count: backgroundOutstanding }) + {visibleOutstanding > 0 + ? t("running", { count: visibleOutstanding }) : t("settling")}
diff --git a/src/components/message/sub-agent-session-dialog.test.tsx b/src/components/message/sub-agent-session-dialog.test.tsx index c8ad07db8..8f841d3ab 100644 --- a/src/components/message/sub-agent-session-dialog.test.tsx +++ b/src/components/message/sub-agent-session-dialog.test.tsx @@ -222,6 +222,7 @@ function makeConnState(overrides: Partial): ConnectionState { configStaleKind: null, configStaleDismissed: false, backgroundOutstanding: 0, + backgroundActivityAt: null, backgroundSettleSyncingSince: null, outOfTurnToolCalls: null, ...overrides, diff --git a/src/contexts/acp-connections-context.test.tsx b/src/contexts/acp-connections-context.test.tsx index 1924fe549..98e3669d4 100644 --- a/src/contexts/acp-connections-context.test.tsx +++ b/src/contexts/acp-connections-context.test.tsx @@ -2366,6 +2366,7 @@ describe("HYDRATE_FROM_SNAPSHOT last_error recovery", () => { configStale: false, configStaleKind: null, backgroundOutstanding: 0, + backgroundActivityAt: null, activeDelegations: [], lastErrorDetails: null, ...overrides, diff --git a/src/contexts/acp-connections-context.tsx b/src/contexts/acp-connections-context.tsx index 2f01dc333..2006b7361 100644 --- a/src/contexts/acp-connections-context.tsx +++ b/src/contexts/acp-connections-context.tsx @@ -35,6 +35,7 @@ import { acpFindConnectionForConversation, } from "@/lib/api" import { denormalizeSnapshot } from "@/lib/snapshot-denormalize" +import { visibleBackgroundOutstanding } from "@/lib/background-activity" import { buildDelegationSeedEnvelopes } from "@/lib/delegation-seed" import { isConnectionBusy, @@ -297,6 +298,12 @@ export interface ConnectionState { * and drives the "background tasks running" chip. */ backgroundOutstanding: number + /** + * Epoch ms of the last `background_activity` event, or the snapshot's + * `background_activity_at`. The chip and idle sweep treat a count whose + * heartbeat is older than the keepalive window as already settled. + */ + backgroundActivityAt: number | null /** * Epoch ms of the most recent `background_activity` event that settled a * task, cleared when the follow-up overlay turns start arriving. Bridges @@ -1312,6 +1319,7 @@ function connectionsReducer( configStaleKind: null, configStaleDismissed: false, backgroundOutstanding: 0, + backgroundActivityAt: null, backgroundSettleSyncingSince: null, outOfTurnToolCalls: null, }) @@ -1370,6 +1378,7 @@ function connectionsReducer( configStaleKind: null, configStaleDismissed: false, backgroundOutstanding: 0, + backgroundActivityAt: null, backgroundSettleSyncingSince: null, outOfTurnToolCalls: null, }) @@ -1493,6 +1502,9 @@ function connectionsReducer( // recovers the pending-background count the one-shot events won't // replay for it (sweep exemption + chip). backgroundOutstanding: action.patch.backgroundOutstanding, + backgroundActivityAt: + action.patch.backgroundActivityAt ?? + (action.patch.backgroundOutstanding > 0 ? Date.now() : null), sessionFailures: mergedSessionFailures, error: action.patch.lastError, lastAppliedSeq: action.patch.eventSeq, @@ -1611,6 +1623,7 @@ function connectionsReducer( next.set(action.contextKey, { ...conn, backgroundOutstanding: action.outstanding, + backgroundActivityAt: Date.now(), backgroundSettleSyncingSince: syncingSince, }) return next @@ -4619,7 +4632,15 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { // background task with it. The backend watcher settles or max-age // expires the accounting and emits `outstanding: 0`, which re-arms // this sweep for the connection. - if (conn.backgroundOutstanding > 0) continue + if ( + visibleBackgroundOutstanding( + conn.backgroundOutstanding, + conn.backgroundActivityAt, + now + ) > 0 + ) { + continue + } const lastActive = lastActivityRef.current.get(contextKey) ?? 0 if (now - lastActive > CONNECTION_IDLE_TIMEOUT_MS) { toDisconnect.push({ diff --git a/src/hooks/use-connection.ts b/src/hooks/use-connection.ts index 6fd385858..d88549b18 100644 --- a/src/hooks/use-connection.ts +++ b/src/hooks/use-connection.ts @@ -89,6 +89,8 @@ export interface UseConnectionReturn { * backend watcher). Drives the "background tasks running" chip; non-zero * also exempts the connection from the idle sweeps. */ backgroundOutstanding: number + /** Epoch ms of the last `background_activity` event / snapshot heartbeat. */ + backgroundActivityAt: number | null /** Epoch ms while a settled background task's follow-up reply is still being * generated/surfaced (cleared when overlay turns arrive). Drives the chip's * transient "syncing results" state so the gap after the running count @@ -237,6 +239,7 @@ export function useConnection(contextKey: string): UseConnectionReturn { const configStaleDismissed = connection?.configStaleDismissed ?? false const isDelegationChild = connection?.isDelegationChild ?? false const backgroundOutstanding = connection?.backgroundOutstanding ?? 0 + const backgroundActivityAt = connection?.backgroundActivityAt ?? null const backgroundSettleSyncingSince = connection?.backgroundSettleSyncingSince ?? null @@ -343,6 +346,7 @@ export function useConnection(contextKey: string): UseConnectionReturn { configStaleDismissed, isDelegationChild, backgroundOutstanding, + backgroundActivityAt, backgroundSettleSyncingSince, connect, disconnect, @@ -383,6 +387,7 @@ export function useConnection(contextKey: string): UseConnectionReturn { configStaleDismissed, isDelegationChild, backgroundOutstanding, + backgroundActivityAt, backgroundSettleSyncingSince, connect, disconnect, diff --git a/src/lib/background-activity.test.ts b/src/lib/background-activity.test.ts new file mode 100644 index 000000000..5d8f2e12b --- /dev/null +++ b/src/lib/background-activity.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest" + +import { + BACKGROUND_KEEPALIVE_MAX_MS, + parseBackgroundActivityAt, + visibleBackgroundOutstanding, +} from "./background-activity" + +describe("visibleBackgroundOutstanding", () => { + it("hides a zero count", () => { + expect(visibleBackgroundOutstanding(0, Date.now(), Date.now())).toBe(0) + }) + + it("keeps a fresh positive count", () => { + const now = 1_700_000_000_000 + expect(visibleBackgroundOutstanding(2, now - 60_000, now)).toBe(2) + }) + + it("drops a count whose heartbeat is older than the keepalive window", () => { + const now = 1_700_000_000_000 + expect( + visibleBackgroundOutstanding( + 2, + now - BACKGROUND_KEEPALIVE_MAX_MS - 1, + now + ) + ).toBe(0) + }) + + it("treats a missing timestamp as live so older servers stay honest", () => { + expect(visibleBackgroundOutstanding(2, null, Date.now())).toBe(2) + }) +}) + +describe("parseBackgroundActivityAt", () => { + it("parses an RFC3339 snapshot timestamp", () => { + expect(parseBackgroundActivityAt("2026-08-17T12:00:00.000Z")).toBe( + Date.parse("2026-08-17T12:00:00.000Z") + ) + }) + + it("returns null for missing or junk values", () => { + expect(parseBackgroundActivityAt(null)).toBeNull() + expect(parseBackgroundActivityAt("")).toBeNull() + expect(parseBackgroundActivityAt("not-a-date")).toBeNull() + }) +}) diff --git a/src/lib/background-activity.ts b/src/lib/background-activity.ts new file mode 100644 index 000000000..a0d6970d0 --- /dev/null +++ b/src/lib/background-activity.ts @@ -0,0 +1,37 @@ +/** + * How long a non-zero `background_outstanding` count stays visible / sweep- + * exempt after the last accounting event. + * + * Matches the backend default of `CODEG_ACP_BACKGROUND_KEEPALIVE_MAX_SECS` + * (3600). The Claude transcript watcher already drops un-settled tasks after + * that window and emits `outstanding: 0`. Grok background subagents have no + * equivalent ticker, and a snapshot can hydrate a count whose heartbeat is + * already dead — without this clock the chip and the frontend idle sweep + * treat a stranded count as immortal. + */ +export const BACKGROUND_KEEPALIVE_MAX_MS = 3_600_000 + +/** + * Count the chip / sweep should honor right now. `activityAtMs` is the last + * `background_activity` event (or the snapshot's `background_activity_at`). + * A missing timestamp with a positive count is treated as live so a server + * that omits the new field does not blank a genuine in-flight task. + */ +export function visibleBackgroundOutstanding( + outstanding: number, + activityAtMs: number | null | undefined, + nowMs: number +): number { + if (outstanding <= 0) return 0 + if (activityAtMs == null) return outstanding + if (nowMs - activityAtMs >= BACKGROUND_KEEPALIVE_MAX_MS) return 0 + return outstanding +} + +export function parseBackgroundActivityAt( + raw: string | null | undefined +): number | null { + if (!raw) return null + const ms = Date.parse(raw) + return Number.isFinite(ms) ? ms : null +} diff --git a/src/lib/connection-teardown.ts b/src/lib/connection-teardown.ts index b73dd66ad..237384738 100644 --- a/src/lib/connection-teardown.ts +++ b/src/lib/connection-teardown.ts @@ -11,6 +11,7 @@ */ import { extractAppCommandError, toErrorMessage } from "@/lib/app-error" +import { visibleBackgroundOutstanding } from "@/lib/background-activity" /** * Work that an `acpDisconnect` would DESTROY rather than merely detach from. @@ -27,8 +28,16 @@ import { extractAppCommandError, toErrorMessage } from "@/lib/app-error" export function isConnectionBusy(conn: { status: string | null backgroundOutstanding: number + backgroundActivityAt?: number | null }): boolean { - return conn.status === "prompting" || conn.backgroundOutstanding > 0 + return ( + conn.status === "prompting" || + visibleBackgroundOutstanding( + conn.backgroundOutstanding, + conn.backgroundActivityAt ?? null, + Date.now() + ) > 0 + ) } // Must mirror `AcpError::ConnectionNotFound`'s code in diff --git a/src/lib/snapshot-denormalize.ts b/src/lib/snapshot-denormalize.ts index 3b9abb749..d85aea432 100644 --- a/src/lib/snapshot-denormalize.ts +++ b/src/lib/snapshot-denormalize.ts @@ -15,6 +15,7 @@ import type { SessionUsageUpdateInfo, ToolCallState, } from "@/lib/types" +import { parseBackgroundActivityAt } from "@/lib/background-activity" import type { LiveContentBlock as LocalLiveContentBlock, @@ -72,6 +73,8 @@ export interface SnapshotPatch { * the one-shot `background_activity` events won't replay. `0` when the * server omitted the field. */ backgroundOutstanding: number + /** Epoch ms of `background_activity_at`, or `null` when the server omitted it. */ + backgroundActivityAt: number | null /** AIR typed session failure table carried by the snapshot — resolved * entries and their revision watermarks included. MERGED into the in-memory * table by the monotonic per-id rule (`mergeSessionFailures`) on BOTH @@ -152,6 +155,9 @@ export function denormalizeSnapshot(wire: LiveSessionSnapshot): SnapshotPatch { configStale: wire.config_stale ?? false, configStaleKind: wire.config_stale_kind ?? null, backgroundOutstanding: wire.background_outstanding ?? 0, + backgroundActivityAt: parseBackgroundActivityAt( + wire.background_activity_at + ), sessionFailures: wire.session_failures ?? [], lastError, lastErrorDetails, diff --git a/src/lib/types.ts b/src/lib/types.ts index f57a24ecf..e709f19a1 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -2299,6 +2299,10 @@ export interface LiveSessionSnapshot { * mid-episode recover the pending count the one-shot `background_activity` * events won't replay. Absent / omitted when zero. */ background_outstanding?: number + /** RFC3339 time of the last `BackgroundActivity` event. Absent when + * outstanding is zero / on older servers. The chip hides a count whose + * heartbeat is older than the keepalive window. */ + background_activity_at?: string | null /** Whether this agent has the `check_user_feedback` tool (fixed at launch). * The frontend gates the feedback bar on this — the agent's real capability — * not the (possibly later-toggled) global setting. Absent → `false`. */