agentmux_srv/backend/reactive/
activity_watcher.rs1use 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
57const SWEEP_INTERVAL_SECS: u64 = 20;
59
60pub(crate) const MAX_ATTEMPTS: u32 = 3;
63
64const WORD_TARGET: u32 = 8;
67
68#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
70pub(crate) struct Attempts {
71 pub count: u32,
73 pub last_size: Option<i64>,
75}
76
77pub(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
92pub 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 let in_flight: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
99 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 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 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 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}