1#[path = "alert_render.rs"]
2pub mod alert_render;
3#[path = "repeat_breaker.rs"]
4pub mod repeat_breaker;
5
6use std::path::Path;
7
8use crate::context::AppContext;
9use crate::protocol::Response;
10
11pub fn append_repeat_breaker_reminder(
12 text: &mut String,
13 session_id: &str,
14 intervention: &repeat_breaker::RepeatIntervention,
15) {
16 let count = intervention.count;
17 let span_seconds = intervention.span.as_secs();
18 let ordinal = ordinal(count);
19 let instruction = if repeat_breaker::escalation_starts_at(count) {
28 "The turn must end now with no further tool call. If you are waiting on a task or CI run, use a background task with a watch (or the background handle you already hold) and end the turn. If you are already watching, let the watch return before calling again."
29 } else {
30 "If you are waiting on a task or CI run, use a background task with a watch (or the background handle you already hold) and end the turn. If you are already watching, let the watch return before calling again."
31 };
32 let reminder = format!(
33 "<system-reminder>\nThis is the {ordinal} identical call (same command, same output) in {span_seconds}s. This call is not returning anything new. {instruction}\n</system-reminder>"
34 );
35 if text.is_empty() {
36 *text = reminder;
37 } else {
38 text.push_str("\n\n");
39 text.push_str(&reminder);
40 }
41 log::info!(
42 "repeat_breaker fired session={} tool={} count={} span_ms={}",
43 session_id,
44 intervention.tool,
45 count,
46 intervention.span.as_millis()
47 );
48}
49
50fn ordinal(count: u64) -> String {
51 let suffix = if (11..=13).contains(&(count % 100)) {
52 "th"
53 } else {
54 match count % 10 {
55 1 => "st",
56 2 => "nd",
57 3 => "rd",
58 _ => "th",
59 }
60 };
61 format!("{count}{suffix}")
62}
63
64pub fn finalize_response(
68 response: &mut Response,
69 ctx: &AppContext,
70 session_id: &str,
71 attach_command: &str,
72) {
73 finalize_response_with_bg_completions(response, ctx, session_id, attach_command, true);
74}
75
76pub fn finalize_response_with_bg_completions(
79 response: &mut Response,
80 ctx: &AppContext,
81 session_id: &str,
82 attach_command: &str,
83 allow_bg_completions: bool,
84) {
85 if allow_bg_completions {
86 attach_bg_completions(response, ctx, session_id, attach_command);
87 }
88 let plane_live = publish_fleet_status(response, ctx, session_id);
89
90 if response.data.get("text").is_none()
94 && !alert_render::is_excluded_finalization_command(attach_command)
95 {
96 attach_status_bar_after_publish(response, ctx, plane_live);
97 }
98}
99
100pub fn finalize_response_for_dispatch_root(
103 response: &mut Response,
104 ctx: &AppContext,
105 alerts: &mut alert_render::AlertEngine,
106 session_id: &str,
107 dispatch_root: &Path,
108 attach_command: &str,
109 allow_bg_completions: bool,
110) {
111 if allow_bg_completions {
112 attach_bg_completions(response, ctx, session_id, attach_command);
113 }
114 let _ = publish_fleet_status(response, ctx, session_id);
115 attach_alert_block(response, alerts, session_id, dispatch_root, attach_command);
116}
117
118fn attach_alert_block(
119 response: &mut Response,
120 alerts: &mut alert_render::AlertEngine,
121 session_id: &str,
122 dispatch_root: &Path,
123 command: &str,
124) {
125 let Some(text) = response
126 .data
127 .as_object_mut()
128 .and_then(|data| data.get_mut("text"))
129 .and_then(|value| value.as_str())
130 .map(str::to_string)
131 else {
132 return;
133 };
134
135 if text.contains("<system-reminder>") {
138 return;
139 }
140 let Some(alert) = alerts.finalize(session_id, dispatch_root, command) else {
141 return;
142 };
143 let joined = if text.is_empty() {
144 alert.text
145 } else {
146 format!("{text}\n\n{}", alert.text)
147 };
148 if let Some(data) = response.data.as_object_mut() {
149 data.insert("text".to_string(), serde_json::Value::String(joined));
150 }
151}
152
153pub enum DispatchOutcome {
154 Immediate(Response),
155 Deferred(PendingResponse),
156}
157
158pub type PendingResponsePoll = Box<dyn FnMut(&AppContext) -> Option<Response> + Send>;
159pub type PendingResponseShutdown = Box<dyn FnMut(&AppContext) -> Response + Send>;
160
161pub struct PendingResponse {
162 pub request_id: String,
163 pub session_id: String,
164 pub attach_command: String,
165 pub poll: PendingResponsePoll,
166 pub cancellation: Option<crate::executor::JobCancellation>,
170 pub on_shutdown: Option<PendingResponseShutdown>,
174}
175
176pub struct ResolvedPending {
177 pub response: Response,
178 pub session_id: String,
179 pub attach_command: String,
180}
181
182#[derive(Default)]
183pub struct PendingResponses {
184 entries: Vec<PendingResponse>,
185}
186
187impl PendingResponses {
188 pub fn register(&mut self, pending: PendingResponse) {
189 self.entries.retain(|entry| {
190 let keep = entry.request_id != pending.request_id;
191 if !keep {
192 if let Some(cancellation) = &entry.cancellation {
193 cancellation.request_cancel();
194 }
195 }
196 keep
197 });
198 self.entries.push(pending);
199 }
200
201 pub fn cancel_request(&mut self, request_id: &str) -> bool {
204 let Some(entry) = self
205 .entries
206 .iter()
207 .find(|entry| entry.request_id == request_id)
208 else {
209 return false;
210 };
211 let Some(cancellation) = &entry.cancellation else {
212 return false;
213 };
214 cancellation.request_cancel();
215 true
216 }
217
218 pub fn poll_ready(&mut self, ctx: &AppContext) -> Vec<ResolvedPending> {
219 let mut ready = Vec::new();
220 let mut waiting = Vec::with_capacity(self.entries.len());
221
222 for mut pending in self.entries.drain(..) {
223 if let Some(response) = (pending.poll)(ctx) {
224 ready.push(ResolvedPending {
225 response,
226 session_id: pending.session_id,
227 attach_command: pending.attach_command,
228 });
229 } else {
230 waiting.push(pending);
231 }
232 }
233
234 self.entries = waiting;
235 ready
236 }
237
238 pub fn is_empty(&self) -> bool {
239 self.entries.is_empty()
240 }
241
242 pub fn drain_on_shutdown(&mut self) {
243 for pending in self.entries.drain(..) {
244 if let Some(cancellation) = &pending.cancellation {
245 cancellation.request_cancel();
246 }
247 }
248 }
249
250 pub fn drain_on_shutdown_with(&mut self, ctx: &AppContext) -> Vec<ResolvedPending> {
253 self.entries
254 .drain(..)
255 .filter_map(|mut pending| {
256 if let Some(cancellation) = &pending.cancellation {
257 cancellation.request_cancel();
258 }
259 let response = (pending.on_shutdown.as_mut()?)(ctx);
260 Some(ResolvedPending {
261 response,
262 session_id: pending.session_id,
263 attach_command: pending.attach_command,
264 })
265 })
266 .collect()
267 }
268}
269
270pub fn attach_bg_completions(
271 response: &mut Response,
272 ctx: &AppContext,
273 session_id: &str,
274 command: &str,
275) {
276 if matches!(
277 command,
278 "configure"
279 | "bash_abort_inflight"
280 | "bash_status"
281 | "bash_write"
282 | "bash_promote"
283 | "bash_wait_detach"
284 | "bash_regex_match"
285 | "bash_drain_completions"
286 | "bash_notify"
287 | "bash_unnotify"
288 | "bash_ack_completions"
289 ) {
290 return;
291 }
292 if !ctx
293 .bash_background()
294 .has_completions_for_session(Some(session_id))
295 {
296 return;
297 }
298 let completions = ctx
299 .bash_background()
300 .drain_completions_for_session(Some(session_id));
301 if completions.is_empty() {
302 return;
303 }
304 let value = serde_json::json!(completions);
305 match response.data.as_object_mut() {
306 Some(data) => {
307 data.insert("bg_completions".to_string(), value);
308 }
309 None => {
310 response.data = serde_json::json!({ "bg_completions": value });
311 }
312 }
313}
314
315fn aft_status_segment(counts: &crate::context::StatusBarCounts) -> String {
316 let stale_mark = if counts.tier2_stale { "~" } else { "" };
317 format!(
321 "AFT E{} W{} | {}D{} U{} C{} | T{}",
322 counts.errors,
323 counts.warnings,
324 stale_mark,
325 counts.dead_code,
326 counts.unused_exports,
327 counts.duplicates,
328 counts.todos
329 )
330}
331
332fn holder_owns_status_bar(plane_live: bool, harness: Option<&crate::harness::Harness>) -> bool {
333 plane_live && matches!(harness, Some(crate::harness::Harness::Opencode))
334}
335
336fn publish_fleet_status(
339 response: &mut Response,
340 ctx: &AppContext,
341 session_id: &str,
342) -> Option<bool> {
343 if response
346 .data
347 .as_object_mut()
348 .and_then(|data| data.remove("_aft_suppress_status_bar"))
349 .is_some()
350 {
351 return None;
352 }
353
354 let local_counts = ctx.status_bar_counts();
355 let harness = ctx.harness_opt();
356 let plane_live = ctx.fleet_status_client().is_some_and(|client| {
357 let config = ctx.config();
358 let Some(project_root) = config.project_root.as_deref() else {
359 return false;
360 };
361 let harness_label = harness
362 .as_ref()
363 .map(crate::harness::Harness::wire_label)
364 .unwrap_or_else(|| "unknown".to_string());
365 let aft_text = local_counts
366 .as_ref()
367 .map(aft_status_segment)
368 .unwrap_or_default();
369 client.publish(project_root, &harness_label, session_id, &aft_text)
370 });
371 Some(plane_live)
372}
373
374pub fn attach_status_bar(
377 response: &mut Response,
378 ctx: &AppContext,
379 session_id: &str,
380 command: &str,
381) {
382 if alert_render::is_excluded_finalization_command(command) {
383 return;
384 }
385 let plane_live = publish_fleet_status(response, ctx, session_id);
386 attach_status_bar_after_publish(response, ctx, plane_live);
387}
388
389fn attach_status_bar_after_publish(
390 response: &mut Response,
391 ctx: &AppContext,
392 plane_live: Option<bool>,
393) {
394 let Some(plane_live) = plane_live else {
395 return;
396 };
397 let harness = ctx.harness_opt();
398 if holder_owns_status_bar(plane_live, harness.as_ref()) {
399 return;
400 }
401 let Some(counts) = ctx.status_bar_counts() else {
402 return;
403 };
404 if !ctx.should_emit_status_bar(&counts) {
405 return;
406 }
407 let value = serde_json::json!({
408 "errors": counts.errors,
409 "warnings": counts.warnings,
410 "dead_code": counts.dead_code,
411 "unused_exports": counts.unused_exports,
412 "duplicates": counts.duplicates,
413 "todos": counts.todos,
414 "tier2_stale": counts.tier2_stale,
415 });
416 match response.data.as_object_mut() {
417 Some(data) => {
418 data.insert("status_bar".to_string(), value);
419 }
420 None => {
421 response.data = serde_json::json!({ "status_bar": value });
422 }
423 }
424}
425
426#[cfg(test)]
427mod tests {
428 use std::path::PathBuf;
429
430 use super::{
431 aft_status_segment, finalize_response_with_bg_completions, holder_owns_status_bar,
432 PendingResponse, PendingResponses,
433 };
434 use crate::config::Config;
435 use crate::context::{AppContext, StatusBarCounts};
436 use crate::fleet_status::FleetStatusClient;
437 use crate::harness::Harness;
438 use crate::parser::TreeSitterProvider;
439 use crate::protocol::Response;
440
441 #[test]
442 fn live_holder_retires_only_opencode_response_bars() {
443 assert_eq!(
444 (
445 holder_owns_status_bar(true, Some(&Harness::Opencode)),
446 holder_owns_status_bar(true, Some(&Harness::Runner)),
447 ),
448 (true, false)
449 );
450 assert!(!holder_owns_status_bar(true, Some(&Harness::Pi)));
451 assert!(!holder_owns_status_bar(false, Some(&Harness::Opencode)));
452 }
453
454 #[test]
455 fn pre_discovery_publish_does_not_trip_the_holder_ownership_gate() {
456 let ctx = AppContext::new(
457 Box::new(TreeSitterProvider::new()),
458 Config {
459 project_root: Some(PathBuf::from("/tmp/project")),
460 ..Config::default()
461 },
462 );
463 ctx.set_harness(Harness::Opencode);
464 ctx.update_status_bar_tier2(Some(21), Some(12), Some(13), Some(14), false);
465 let (client, mut wire_rx) = FleetStatusClient::dial_channel(1);
466 ctx.install_fleet_status_client(Some(client));
467 let mut response = Response::success("status", serde_json::json!({}));
468
469 finalize_response_with_bg_completions(&mut response, &ctx, "session-1", "echo", false);
470
471 assert!(response.data.get("status_bar").is_none());
472 let publish = wire_rx.try_recv().expect("single discovery publish");
473 assert_eq!(
474 publish.body()["text"],
475 "",
476 "missing diagnostics stay absent instead of being published as E0 W0"
477 );
478 assert!(
479 wire_rx.try_recv().is_err(),
480 "response published more than once"
481 );
482 publish.complete_unavailable();
483 }
484
485 #[test]
486 fn published_segment_bytes_are_self_labeled() {
487 let counts = StatusBarCounts {
488 errors: 2,
489 warnings: 5,
490 dead_code: 331,
491 unused_exports: 221,
492 duplicates: 1159,
493 todos: 8,
494 tier2_stale: false,
495 };
496 assert_eq!(
497 format!("[{}]", aft_status_segment(&counts)),
498 "[AFT E2 W5 | D331 U221 C1159 | T8]"
499 );
500 }
501
502 #[test]
503 fn shutdown_delivery_emits_terminal_before_removing_entry() {
504 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
505 let mut pending = PendingResponses::default();
506 pending.register(PendingResponse {
507 request_id: "inspect-shutdown".to_string(),
508 session_id: String::new(),
509 attach_command: String::new(),
510 poll: Box::new(|_| None),
511 cancellation: None,
512 on_shutdown: Some(Box::new(|_| {
513 Response::error("inspect-shutdown", "daemon_shutdown", "shutdown")
514 })),
515 });
516
517 let resolved = pending.drain_on_shutdown_with(&ctx);
518 assert_eq!(resolved.len(), 1);
519 assert_eq!(resolved[0].response.id, "inspect-shutdown");
520 assert!(pending.is_empty());
521 }
522
523 #[test]
524 fn published_segment_stale_marker_bytes_are_self_labeled() {
525 let counts = StatusBarCounts {
526 dead_code: 10,
527 tier2_stale: true,
528 ..StatusBarCounts::default()
529 };
530 assert_eq!(
531 format!("[{}]", aft_status_segment(&counts)),
532 "[AFT E0 W0 | ~D10 U0 C0 | T0]"
533 );
534 }
535}