Skip to main content

agentmux_srv/backend/reactive/
activity_watcher.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Empty-title recovery: a running agent with conversation and no usable
5//! session title gets one, written straight to its block's
6//! `term:ambient_summary`, which the pane header and the Swarm row read.
7//!
8//! The title is otherwise computed only when a human submits a message
9//! (`useAgentActivitySummary.ts`). An agent driven by jekts or tool work, one
10//! reattached mid-turn, or one whose title call failed or abstained could sit on
11//! an empty title for as long as it ran. This sweep is the second route.
12//!
13//! It replaces what this module used to do: a "what is being worked on" summary
14//! every 20 s for every running agent, published as an `agent:summary` event that
15//! nothing subscribed to, so every call was spent and its output went nowhere.
16//! Now an agent that already has a title costs nothing here.
17//! docs/specs/SPEC_AMBIENT_SWARM_SUMMARY_HARDENING_2026_10_02.md sections 2.1
18//! (defects 2 and 5), 5.6 and 5.7.
19//!
20//! Each call goes through `crate::ambient::tasks::generate_recovered_title`, so
21//! the Ambient Model Call gateway admits, cancels and accounts for it under its
22//! own purpose tag, distinct from the pane's own title request.
23//!
24//! Cost controls:
25//!   - only agents whose controller is running (`STATUS_RUNNING`) and that have no usable title;
26//!   - only agents whose `output` is in a shape the digest can read
27//!     (`digest::reads_output_format`); another provider's file holds no
28//!     conversation it can find;
29//!   - only when the block's `output` has grown since the last completed attempt;
30//!   - at most [`MAX_ATTEMPTS`] completed attempts per block while the title stays
31//!     empty, counted again from zero once a title exists, so an agent whose
32//!     activity never shows what the work is (the model abstains) stops costing;
33//!   - no conversation in the digest means no call at all (`digest` returns
34//!     `None` for activity with no user or assistant text);
35//!   - one call in flight per block, waiting in the background class's queue
36//!     (`ambient::limits`) across all blocks;
37//!   - per-block bookkeeping is pruned each tick against the registration list.
38
39use std::collections::{HashMap, HashSet};
40use std::sync::{Arc, Mutex};
41use std::time::Duration;
42
43use tokio::time::interval;
44
45use crate::ambient::digest;
46use crate::ambient::title::{store_title, Replace, META_TITLE};
47use crate::ambient::validate::is_usable_title;
48use crate::backend::blockcontroller::core::broadcast_block_update;
49use crate::backend::blockcontroller::{get_block_controller_status, STATUS_RUNNING};
50use crate::backend::eventbus::EventBus;
51use crate::backend::obj::{self, Block};
52use crate::backend::storage::filestore::FileStore;
53use crate::backend::storage::store::Store;
54
55use super::get_global_handler;
56
57/// How often to sweep registered agents.
58const SWEEP_INTERVAL_SECS: u64 = 20;
59
60/// Completed attempts per block while its title stays empty. Bounded, as the
61/// `lys` design bounds its retries (spec section 3, research point 2).
62pub(crate) const MAX_ATTEMPTS: u32 = 3;
63
64/// Word budget for a recovered title: within the range the pane's own request
65/// uses (5 to 12, by pane width), so a recovered title looks like any other.
66const WORD_TARGET: u32 = 8;
67
68/// Per-block state the sweep keeps between ticks.
69#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
70pub(crate) struct Attempts {
71    /// Completed (billed) attempts since the title was last seen non-empty.
72    pub count: u32,
73    /// The `output` size at the last completed attempt.
74    pub last_size: Option<i64>,
75}
76
77/// Why a block is not attempted this tick, or `None` to attempt it. Pure, so the
78/// cost rules are testable without a model, a store or a clock.
79pub(crate) fn skip_reason(has_title: bool, attempts: Attempts, output_size: i64) -> Option<&'static str> {
80    if has_title {
81        return Some("has a title");
82    }
83    if attempts.count >= MAX_ATTEMPTS {
84        return Some("attempts exhausted");
85    }
86    if attempts.last_size == Some(output_size) {
87        return Some("no new output");
88    }
89    None
90}
91
92/// Run the recovery sweep. Never returns.
93pub async fn run_agent_summary_loop(mstore: Arc<Store>, filestore: Arc<FileStore>, event_bus: Arc<EventBus>) {
94    let mut ticker = interval(Duration::from_secs(SWEEP_INTERVAL_SECS));
95    let attempts: Arc<Mutex<HashMap<String, Attempts>>> = Arc::new(Mutex::new(HashMap::new()));
96    // One call per block at a time: a slow call (up to the CLI timeout) must not
97    // be dispatched again by the next tick. Every insert has a matching remove.
98    let in_flight: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
99    // The gateway's generation only has to increase per (block, purpose).
100    let mut tick: u64 = 0;
101
102    loop {
103        ticker.tick().await;
104        tick += 1;
105
106        let agents = get_global_handler().list_agents();
107        let registered: HashSet<String> = agents.iter().map(|a| a.block_id.clone()).collect();
108        attempts.lock().unwrap().retain(|block_id, _| registered.contains(block_id));
109
110        for agent in agents {
111            let block_id = agent.block_id.clone();
112
113            let Some(status) = get_block_controller_status(&block_id) else {
114                continue;
115            };
116            if status.shellprocstatus != STATUS_RUNNING || !status.is_agent_pane {
117                continue;
118            }
119            let Ok(Some(block)) = mstore.get::<Block>(&block_id) else {
120                continue;
121            };
122            let has_title = is_usable_title(&obj::meta_get_string(&block.meta, META_TITLE, ""));
123            if has_title {
124                // A title exists: the budget starts over if it is ever lost.
125                attempts.lock().unwrap().remove(&block_id);
126                continue;
127            }
128            if !digest::reads_output_format(&obj::meta_get_string(&block.meta, "agentOutputFormat", "")) {
129                continue;
130            }
131            let Ok(Some(output)) = filestore.stat(&block_id, "output") else {
132                continue;
133            };
134            let state = attempts.lock().unwrap().get(&block_id).copied().unwrap_or_default();
135            if skip_reason(false, state, output.size).is_some() {
136                continue;
137            }
138            if !in_flight.lock().unwrap().insert(block_id.clone()) {
139                continue;
140            }
141
142            let mstore = mstore.clone();
143            let filestore = filestore.clone();
144            let event_bus = event_bus.clone();
145            let attempts = attempts.clone();
146            let in_flight = in_flight.clone();
147            let output_size = output.size;
148
149            tokio::spawn(async move {
150                let result =
151                    crate::ambient::tasks::generate_recovered_title(&mstore, &filestore, &block_id, tick, WORD_TARGET)
152                        .await;
153                in_flight.lock().unwrap().remove(&block_id);
154
155                // `None`: nothing ran (no conversation in the digest, no CLI path,
156                // superseded). Not an attempt; a later tick may try again.
157                let Some(generated) = result else {
158                    return;
159                };
160                let attempt = {
161                    let mut map = attempts.lock().unwrap();
162                    let entry = map.entry(block_id.clone()).or_default();
163                    entry.count += 1;
164                    entry.last_size = Some(output_size);
165                    entry.count
166                };
167
168                let Some(title) = generated.text else {
169                    tracing::info!(
170                        block_id = %block_id,
171                        attempt,
172                        max = MAX_ATTEMPTS,
173                        "ambient: title recovery produced nothing usable (abstained or rejected)"
174                    );
175                    return;
176                };
177                // The pane's own request can win the race; it is the better source.
178                match store_title(&mstore, &block_id, &title, Replace::IfEmpty, || true) {
179                    Ok(true) => {
180                        tracing::info!(block_id = %block_id, attempt, title = %title, "ambient: recovered a missing session title");
181                        broadcast_block_update(&mstore, &event_bus, &block_id);
182                    }
183                    Ok(false) => {
184                        tracing::debug!(block_id = %block_id, "ambient: a title appeared while recovery ran; kept it");
185                    }
186                    Err(e) => {
187                        tracing::warn!(block_id = %block_id, error = %e, "ambient: could not store a recovered title");
188                    }
189                }
190            });
191        }
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    fn tried(count: u32, last_size: i64) -> Attempts {
200        Attempts { count, last_size: Some(last_size) }
201    }
202
203    #[test]
204    fn an_agent_with_a_title_costs_nothing() {
205        assert_eq!(skip_reason(true, Attempts::default(), 100), Some("has a title"));
206    }
207
208    #[test]
209    fn an_untitled_agent_is_tried_once_per_growth_of_its_output() {
210        assert_eq!(skip_reason(false, Attempts::default(), 100), None, "first try");
211        assert_eq!(skip_reason(false, tried(1, 100), 100), Some("no new output"));
212        assert_eq!(skip_reason(false, tried(1, 100), 250), None, "new output, try again");
213    }
214
215    #[test]
216    fn attempts_are_bounded_while_the_title_stays_empty() {
217        assert_eq!(skip_reason(false, tried(MAX_ATTEMPTS - 1, 1), 2), None);
218        assert_eq!(skip_reason(false, tried(MAX_ATTEMPTS, 1), 2), Some("attempts exhausted"));
219    }
220}