1use std::collections::{HashMap, HashSet};
4use std::path::{Path, PathBuf};
5use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
6
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9use tracing::warn;
10
11use super::config::{PlanningDirectiveFile, RoleType, TeamConfig, load_planning_directive};
12use super::hierarchy::MemberInstance;
13use super::metrics;
14use super::telegram::TelegramBot;
15use super::watcher::SessionWatcher;
16use super::{pause_marker_path, team_config_dir};
17use crate::task;
18use crate::tmux;
19
20const REVIEW_POLICY_MAX_CHARS: usize = 2_000;
21
22#[cfg_attr(not(test), allow(dead_code))]
25pub fn generate_standup_for(
26 recipient: &MemberInstance,
27 members: &[MemberInstance],
28 watchers: &HashMap<String, SessionWatcher>,
29 states: &HashMap<String, MemberState>,
30 output_lines: usize,
31) -> String {
32 generate_board_aware_standup_for(
33 recipient,
34 members,
35 watchers,
36 states,
37 output_lines,
38 None,
39 &HashMap::new(),
40 )
41}
42
43pub fn generate_board_aware_standup_for(
46 recipient: &MemberInstance,
47 members: &[MemberInstance],
48 watchers: &HashMap<String, SessionWatcher>,
49 states: &HashMap<String, MemberState>,
50 output_lines: usize,
51 board_dir: Option<&Path>,
52 backend_health: &HashMap<String, crate::agent::BackendHealth>,
53) -> String {
54 let board_context = load_board_context(board_dir, members);
55 let mut report = String::new();
56 report.push_str(&format!("=== STANDUP for {} ===\n", recipient.name));
57
58 let direct_reports: Vec<&MemberInstance> = members
60 .iter()
61 .filter(|m| m.reports_to.as_deref() == Some(&recipient.name))
62 .collect();
63
64 if direct_reports.is_empty() {
65 report.push_str("(no direct reports)\n");
66 } else {
67 for member in &direct_reports {
68 let state = states
69 .get(&member.name)
70 .copied()
71 .unwrap_or(MemberState::Idle);
72 let state_str = match state {
73 MemberState::Idle => "idle",
74 MemberState::Working => "working",
75 };
76
77 report.push_str(&format!("\n[{}] status: {}\n", member.name, state_str));
78
79 if let Some(health) = backend_health.get(&member.name) {
80 if !health.is_healthy() {
81 report.push_str(&format!(
82 " backend: {} (agent may be unable to work)\n",
83 health.as_str()
84 ));
85 }
86 }
87
88 if let Some(board_context) = &board_context {
89 let assigned_ids = board_context.assigned_task_ids.get(&member.name);
90 report.push_str(&format!(
91 " assigned tasks: {}\n",
92 format_assigned_task_ids(assigned_ids)
93 ));
94
95 if board_context
96 .idle_with_runnable
97 .contains(member.name.as_str())
98 {
99 report.push_str(" warning: idle while runnable work exists on the board\n");
100 }
101 }
102
103 if let Some(watcher) = watchers.get(&member.name) {
104 let last = watcher.last_lines(output_lines);
105 if !last.trim().is_empty() {
106 report.push_str(" recent output:\n");
107 for line in last.lines().take(output_lines) {
108 report.push_str(&format!(" {line}\n"));
109 }
110 }
111 }
112 }
113 }
114
115 if let Some(board_context) = &board_context {
116 let idle_reports = direct_reports
117 .iter()
118 .filter(|member| {
119 board_context
120 .idle_with_runnable
121 .contains(member.name.as_str())
122 })
123 .map(|member| member.name.as_str())
124 .collect::<Vec<_>>();
125
126 report.push_str("\nWorkflow signals:\n");
127 report.push_str(&format!(
128 " blocked tasks: {}\n",
129 board_context.metrics.blocked_count
130 ));
131 report.push_str(&format!(
132 " oldest review age: {}\n",
133 format_age(board_context.metrics.oldest_review_age_secs)
134 ));
135 if !idle_reports.is_empty() {
136 report.push_str(&format!(
137 " idle with runnable: {}\n",
138 idle_reports.join(", ")
139 ));
140 }
141 let total_merges =
142 board_context.metrics.auto_merge_count + board_context.metrics.manual_merge_count;
143 if total_merges > 0 || board_context.metrics.rework_count > 0 {
144 let auto_rate = board_context
145 .metrics
146 .auto_merge_rate
147 .map(|r| format!("{:.0}%", r * 100.0))
148 .unwrap_or_else(|| "-".to_string());
149 report.push_str(&format!(
150 " review pipeline: auto-merge rate {} | rework {} | nudges {} | escalations {}\n",
151 auto_rate,
152 board_context.metrics.rework_count,
153 board_context.metrics.review_nudge_count,
154 board_context.metrics.review_escalation_count,
155 ));
156 }
157 }
158
159 report.push_str("\n=== END STANDUP ===\n");
160 prepend_review_policy_context(board_dir, report)
161}
162
163fn prepend_review_policy_context(board_dir: Option<&Path>, report: String) -> String {
164 let Some(project_root) = project_root_from_board_dir(board_dir) else {
165 return report;
166 };
167 match load_planning_directive(
168 project_root,
169 PlanningDirectiveFile::ReviewPolicy,
170 REVIEW_POLICY_MAX_CHARS,
171 ) {
172 Ok(Some(policy)) => format!("Review policy context:\n{policy}\n\n{report}"),
173 Ok(None) => report,
174 Err(error) => {
175 warn!(error = %error, "failed to load review policy for standup");
176 report
177 }
178 }
179}
180
181fn project_root_from_board_dir(board_dir: Option<&Path>) -> Option<&Path> {
182 let board_dir = board_dir?;
183 let team_config = board_dir.parent()?;
184 if team_config.file_name()? != "team_config" {
185 return None;
186 }
187 let batty_dir = team_config.parent()?;
188 if batty_dir.file_name()? != ".batty" {
189 return None;
190 }
191 batty_dir.parent()
192}
193
194pub(crate) struct StandupGenerationContext<'a> {
195 pub(crate) project_root: &'a Path,
196 pub(crate) team_config: &'a TeamConfig,
197 pub(crate) members: &'a [MemberInstance],
198 pub(crate) watchers: &'a HashMap<String, SessionWatcher>,
199 pub(crate) states: &'a HashMap<String, MemberState>,
200 pub(crate) pane_map: &'a HashMap<String, String>,
201 pub(crate) telegram_bot: Option<&'a TelegramBot>,
202 pub(crate) paused_standups: &'a HashSet<String>,
203 pub(crate) last_standup: &'a mut HashMap<String, Instant>,
204 pub(crate) backend_health: &'a HashMap<String, crate::agent::BackendHealth>,
205}
206
207pub(crate) fn maybe_generate_standup(context: StandupGenerationContext<'_>) -> Result<Vec<String>> {
208 let StandupGenerationContext {
209 project_root,
210 team_config,
211 members,
212 watchers,
213 states,
214 pane_map,
215 telegram_bot,
216 paused_standups,
217 last_standup,
218 backend_health,
219 } = context;
220 if !team_config.automation.standups {
221 return Ok(Vec::new());
222 }
223 if pause_marker_path(project_root).exists() {
224 return Ok(Vec::new());
225 }
226 let global_interval = team_config.standup.interval_secs;
227 if global_interval == 0 {
228 return Ok(Vec::new());
229 }
230
231 let mut recipients = Vec::new();
232 for role in &team_config.roles {
233 let receives = role.receives_standup.unwrap_or(matches!(
234 role.role_type,
235 RoleType::Manager | RoleType::Architect
236 ));
237 if !receives {
238 continue;
239 }
240 let interval = Duration::from_secs(role.standup_interval_secs.unwrap_or(global_interval));
241 for member in members {
242 if member.role_name == role.name {
243 recipients.push((member.clone(), interval));
244 }
245 }
246 }
247
248 let mut generated_recipients = Vec::new();
249
250 for (recipient, interval) in &recipients {
251 if paused_standups.contains(&recipient.name) {
252 continue;
253 }
254
255 let last = last_standup.get(&recipient.name).copied();
256 let should_fire = match last {
257 Some(instant) => instant.elapsed() >= *interval,
258 None => true,
259 };
260
261 if last.is_none() {
262 last_standup.insert(recipient.name.clone(), Instant::now());
263 continue;
264 }
265 if !should_fire {
266 continue;
267 }
268
269 let board_dir = team_config_dir(project_root).join("board");
270 let report = generate_board_aware_standup_for(
271 recipient,
272 members,
273 watchers,
274 states,
275 team_config.standup.output_lines as usize,
276 Some(&board_dir),
277 backend_health,
278 );
279
280 match recipient.role_type {
281 RoleType::User => {
282 if let Some(bot) = telegram_bot {
283 let chat_id = team_config
284 .roles
285 .iter()
286 .find(|role| {
287 role.role_type == RoleType::User && role.name == recipient.role_name
288 })
289 .and_then(|role| role.channel_config.as_ref())
290 .map(|config| config.target.clone());
291
292 match chat_id {
293 Some(chat_id) => {
294 if let Err(error) = bot.send_message(&chat_id, &report) {
295 warn!(
296 member = %recipient.name,
297 target = %chat_id,
298 error = %error,
299 "failed to send standup via telegram"
300 );
301 } else {
302 generated_recipients.push(recipient.name.clone());
303 }
304 }
305 None => warn!(
306 member = %recipient.name,
307 "telegram standup delivery skipped: missing target"
308 ),
309 }
310 } else {
311 match write_standup_file(project_root, &report) {
312 Ok(path) => {
313 tracing::info!(member = %recipient.name, path = %path.display(), "standup written to file");
314 generated_recipients.push(recipient.name.clone());
315 }
316 Err(error) => warn!(
317 member = %recipient.name,
318 error = %error,
319 "failed to write standup file"
320 ),
321 }
322 }
323 }
324 _ => {
325 if let Some(pane_id) = pane_map.get(&recipient.name) {
326 if let Err(error) = inject_standup(pane_id, &report) {
327 warn!(member = %recipient.name, error = %error, "failed to inject standup");
328 } else {
329 generated_recipients.push(recipient.name.clone());
330 }
331 }
332 }
333 }
334
335 last_standup.insert(recipient.name.clone(), Instant::now());
336 }
337
338 if !generated_recipients.is_empty() {
339 tracing::info!("standups generated and delivered");
340 }
341
342 Ok(generated_recipients)
343}
344
345pub(crate) fn update_timer_for_state(
346 team_config: &TeamConfig,
347 members: &[MemberInstance],
348 paused_standups: &mut HashSet<String>,
349 last_standup: &mut HashMap<String, Instant>,
350 member_name: &str,
351 new_state: MemberState,
352) {
353 if standup_interval_for_member_name(team_config, members, member_name).is_none() {
354 paused_standups.remove(member_name);
355 last_standup.remove(member_name);
356 return;
357 }
358
359 match new_state {
360 MemberState::Working => {
361 paused_standups.insert(member_name.to_string());
362 last_standup.remove(member_name);
363 }
364 MemberState::Idle => {
365 let was_paused = paused_standups.remove(member_name);
366 if was_paused || !last_standup.contains_key(member_name) {
367 last_standup.insert(member_name.to_string(), Instant::now());
368 }
369 }
370 }
371}
372
373pub(crate) fn standup_interval_for_member_name(
374 team_config: &TeamConfig,
375 members: &[MemberInstance],
376 member_name: &str,
377) -> Option<Duration> {
378 let member = members.iter().find(|member| member.name == member_name)?;
379 let role_def = team_config
380 .roles
381 .iter()
382 .find(|role| role.name == member.role_name);
383
384 let receives = role_def
385 .and_then(|role| role.receives_standup)
386 .unwrap_or(matches!(
387 member.role_type,
388 RoleType::Manager | RoleType::Architect
389 ));
390 if !receives {
391 return None;
392 }
393
394 let interval_secs = role_def
395 .and_then(|role| role.standup_interval_secs)
396 .unwrap_or(team_config.standup.interval_secs);
397 Some(Duration::from_secs(interval_secs))
398}
399
400pub(crate) fn restore_timer_state(
401 last_standup_elapsed_secs: HashMap<String, u64>,
402) -> HashMap<String, Instant> {
403 last_standup_elapsed_secs
404 .into_iter()
405 .map(|(member, elapsed_secs)| {
406 (
407 member,
408 Instant::now()
409 .checked_sub(Duration::from_secs(elapsed_secs))
410 .unwrap_or_else(Instant::now),
411 )
412 })
413 .collect()
414}
415
416pub(crate) fn snapshot_timer_state(
417 last_standup: &HashMap<String, Instant>,
418) -> HashMap<String, u64> {
419 last_standup
420 .iter()
421 .map(|(member, instant)| (member.clone(), instant.elapsed().as_secs()))
422 .collect()
423}
424
425pub fn inject_standup(pane_id: &str, standup: &str) -> Result<()> {
427 tmux::load_buffer(standup)?;
428 tmux::paste_buffer(pane_id)?;
429 std::thread::sleep(std::time::Duration::from_millis(500));
431 tmux::send_keys(pane_id, "", true)?;
432 Ok(())
433}
434
435pub fn write_standup_file(project_root: &Path, standup: &str) -> Result<PathBuf> {
437 let standups_dir = project_root.join(".batty").join("standups");
438 std::fs::create_dir_all(&standups_dir)
439 .with_context(|| format!("failed to create {}", standups_dir.display()))?;
440
441 let timestamp = SystemTime::now()
442 .duration_since(UNIX_EPOCH)
443 .context("system clock is before UNIX_EPOCH")?
444 .as_millis();
445 let path = standups_dir.join(format!("{timestamp}.md"));
446
447 std::fs::write(&path, standup)
448 .with_context(|| format!("failed to write {}", path.display()))?;
449 Ok(path)
450}
451
452#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
454#[serde(rename_all = "snake_case")]
455pub enum MemberState {
456 Idle,
457 Working,
458}
459
460#[derive(Debug, Clone)]
461struct BoardContext {
462 metrics: metrics::WorkflowMetrics,
463 assigned_task_ids: HashMap<String, Vec<u32>>,
464 idle_with_runnable: HashSet<String>,
465}
466
467fn load_board_context(
468 board_dir: Option<&Path>,
469 members: &[MemberInstance],
470) -> Option<BoardContext> {
471 let board_dir = board_dir?;
472 let tasks_dir = board_dir.join("tasks");
473 if !tasks_dir.is_dir() {
474 return None;
475 }
476
477 let metrics = metrics::compute_metrics(board_dir, members).ok()?;
478 let tasks = task::load_tasks_from_dir(&tasks_dir).ok()?;
479 let mut assigned_task_ids = HashMap::<String, Vec<u32>>::new();
480
481 for task in tasks
482 .into_iter()
483 .filter(|task| !matches!(task.status.as_str(), "done" | "archived"))
484 {
485 let Some(claimed_by) = task.claimed_by else {
486 continue;
487 };
488 assigned_task_ids
489 .entry(claimed_by)
490 .or_default()
491 .push(task.id);
492 }
493
494 for task_ids in assigned_task_ids.values_mut() {
495 task_ids.sort_unstable();
496 }
497
498 Some(BoardContext {
499 idle_with_runnable: metrics.idle_with_runnable.iter().cloned().collect(),
500 metrics,
501 assigned_task_ids,
502 })
503}
504
505fn format_assigned_task_ids(task_ids: Option<&Vec<u32>>) -> String {
506 let Some(task_ids) = task_ids else {
507 return "none".to_string();
508 };
509
510 if task_ids.is_empty() {
511 "none".to_string()
512 } else {
513 task_ids
514 .iter()
515 .map(|task_id| format!("#{task_id}"))
516 .collect::<Vec<_>>()
517 .join(", ")
518 }
519}
520
521fn format_age(age_secs: Option<u64>) -> String {
522 age_secs
523 .map(|secs| format!("{secs}s"))
524 .unwrap_or_else(|| "n/a".to_string())
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530 use crate::team::config::{
531 AutomationConfig, BoardConfig, OrchestratorPosition, RoleDef, RoleType, StandupConfig,
532 TeamConfig, WorkflowMode, WorkflowPolicy,
533 };
534 use std::path::Path;
535
536 fn make_member(name: &str, role_type: RoleType, reports_to: Option<&str>) -> MemberInstance {
537 MemberInstance {
538 name: name.to_string(),
539 role_name: name.to_string(),
540 role_type,
541 agent: Some("claude".to_string()),
542 prompt: None,
543 reports_to: reports_to.map(|s| s.to_string()),
544 use_worktrees: false,
545 }
546 }
547
548 fn write_task(
549 board_dir: &Path,
550 id: u32,
551 title: &str,
552 status: &str,
553 claimed_by: Option<&str>,
554 blocked: Option<&str>,
555 ) {
556 let tasks_dir = board_dir.join("tasks");
557 std::fs::create_dir_all(&tasks_dir).unwrap();
558 let mut content =
559 format!("---\nid: {id}\ntitle: {title}\nstatus: {status}\npriority: medium\n");
560 if let Some(claimed_by) = claimed_by {
561 content.push_str(&format!("claimed_by: {claimed_by}\n"));
562 }
563 if let Some(blocked) = blocked {
564 content.push_str(&format!("blocked: {blocked}\n"));
565 }
566 content.push_str("class: standard\n---\n\nTask body.\n");
567 std::fs::write(tasks_dir.join(format!("{id:03}-{title}.md")), content).unwrap();
568 }
569
570 #[test]
571 fn standup_shows_only_direct_reports() {
572 let members = vec![
573 make_member("architect", RoleType::Architect, None),
574 make_member("manager", RoleType::Manager, Some("architect")),
575 make_member("eng-1-1", RoleType::Engineer, Some("manager")),
576 make_member("eng-1-2", RoleType::Engineer, Some("manager")),
577 ];
578 let watchers = HashMap::new();
579 let mut states = HashMap::new();
580 states.insert("eng-1-1".to_string(), MemberState::Working);
581 states.insert("eng-1-2".to_string(), MemberState::Idle);
582 states.insert("architect".to_string(), MemberState::Working);
583
584 let manager = &members[1];
586 let report = generate_standup_for(manager, &members, &watchers, &states, 5);
587 assert!(report.contains("[eng-1-1] status: working"));
588 assert!(report.contains("[eng-1-2] status: idle"));
589 assert!(!report.contains("[architect]"));
590 assert!(report.contains("STANDUP for manager"));
591 }
592
593 #[test]
594 fn standup_architect_sees_manager() {
595 let members = vec![
596 make_member("architect", RoleType::Architect, None),
597 make_member("manager", RoleType::Manager, Some("architect")),
598 make_member("eng-1-1", RoleType::Engineer, Some("manager")),
599 ];
600 let watchers = HashMap::new();
601 let states = HashMap::new();
602
603 let architect = &members[0];
604 let report = generate_standup_for(architect, &members, &watchers, &states, 5);
605 assert!(report.contains("[manager]"));
606 assert!(!report.contains("[eng-1-1]"));
607 }
608
609 #[test]
610 fn standup_no_reports_for_engineer() {
611 let members = vec![
612 make_member("manager", RoleType::Manager, None),
613 make_member("eng-1-1", RoleType::Engineer, Some("manager")),
614 ];
615 let watchers = HashMap::new();
616 let states = HashMap::new();
617
618 let eng = &members[1];
619 let report = generate_standup_for(eng, &members, &watchers, &states, 5);
620 assert!(report.contains("no direct reports"));
621 }
622
623 #[test]
624 fn standup_excludes_user_role() {
625 let members = vec![MemberInstance {
626 name: "human".to_string(),
627 role_name: "human".to_string(),
628 role_type: RoleType::User,
629 agent: None,
630 prompt: None,
631 reports_to: None,
632 use_worktrees: false,
633 }];
634 let report =
635 generate_standup_for(&members[0], &members, &HashMap::new(), &HashMap::new(), 5);
636 assert!(!report.contains("[human]"));
637 }
638
639 #[test]
640 fn test_generate_standup_for_formats_various_member_states() {
641 let members = vec![
642 make_member("manager", RoleType::Manager, None),
643 make_member("eng-idle", RoleType::Engineer, Some("manager")),
644 make_member("eng-working", RoleType::Engineer, Some("manager")),
645 ];
646 let mut states = HashMap::new();
647 states.insert("eng-working".to_string(), MemberState::Working);
648
649 let report = generate_standup_for(&members[0], &members, &HashMap::new(), &states, 5);
650
651 assert!(report.contains("=== STANDUP for manager ==="));
652 assert!(report.contains("[eng-idle] status: idle"));
653 assert!(report.contains("[eng-working] status: working"));
654 assert!(report.contains("=== END STANDUP ==="));
655 }
656
657 #[test]
658 fn test_generate_standup_for_empty_members_returns_no_direct_reports() {
659 let recipient = make_member("manager", RoleType::Manager, None);
660 let report = generate_standup_for(&recipient, &[], &HashMap::new(), &HashMap::new(), 5);
661
662 assert!(report.contains("=== STANDUP for manager ==="));
663 assert!(report.contains("(no direct reports)"));
664 assert!(report.contains("=== END STANDUP ==="));
665 }
666
667 #[test]
668 fn test_generate_standup_for_all_same_status_lists_each_direct_report() {
669 let members = vec![
670 make_member("manager", RoleType::Manager, None),
671 make_member("eng-1", RoleType::Engineer, Some("manager")),
672 make_member("eng-2", RoleType::Engineer, Some("manager")),
673 make_member("eng-3", RoleType::Engineer, Some("manager")),
674 ];
675 let states = HashMap::from([
676 ("eng-1".to_string(), MemberState::Working),
677 ("eng-2".to_string(), MemberState::Working),
678 ("eng-3".to_string(), MemberState::Working),
679 ]);
680
681 let report = generate_standup_for(&members[0], &members, &HashMap::new(), &states, 5);
682
683 assert_eq!(report.matches("status: working").count(), 3);
684 assert!(report.contains("[eng-1] status: working"));
685 assert!(report.contains("[eng-2] status: working"));
686 assert!(report.contains("[eng-3] status: working"));
687 }
688
689 #[test]
690 fn board_aware_standup_appends_task_ids_and_workflow_signals() {
691 let tmp = tempfile::tempdir().unwrap();
692 let board_dir = tmp.path().join(".batty").join("team_config").join("board");
693 write_task(&board_dir, 1, "active", "in-progress", Some("eng-1"), None);
694 write_task(
695 &board_dir,
696 2,
697 "blocked",
698 "blocked",
699 Some("eng-2"),
700 Some("waiting"),
701 );
702 write_task(&board_dir, 3, "review", "review", Some("eng-2"), None);
703 write_task(&board_dir, 4, "runnable", "todo", None, None);
704
705 let members = vec![
706 make_member("manager", RoleType::Manager, None),
707 make_member("eng-1", RoleType::Engineer, Some("manager")),
708 make_member("eng-2", RoleType::Engineer, Some("manager")),
709 make_member("eng-3", RoleType::Engineer, Some("manager")),
710 ];
711 let states = HashMap::from([
712 ("eng-1".to_string(), MemberState::Working),
713 ("eng-2".to_string(), MemberState::Working),
714 ("eng-3".to_string(), MemberState::Idle),
715 ]);
716
717 let report = generate_board_aware_standup_for(
718 &members[0],
719 &members,
720 &HashMap::new(),
721 &states,
722 5,
723 Some(&board_dir),
724 &HashMap::new(),
725 );
726
727 assert!(report.contains("assigned tasks: #1"));
728 assert!(report.contains("assigned tasks: #2, #3"));
729 assert!(report.contains("[eng-3] status: idle"));
730 assert!(report.contains("assigned tasks: none"));
731 assert!(report.contains("warning: idle while runnable work exists on the board"));
732 assert!(report.contains("Workflow signals:"));
733 assert!(report.contains("blocked tasks: 1"));
734 assert!(report.contains("idle with runnable: eng-3"));
735 assert!(report.contains("oldest review age: "));
736 assert!(!report.contains("oldest review age: n/a"));
737 }
738
739 #[test]
740 fn board_aware_standup_falls_back_when_board_is_missing() {
741 let tmp = tempfile::tempdir().unwrap();
742 let missing_board_dir = tmp.path().join("missing-board");
743 let members = vec![
744 make_member("manager", RoleType::Manager, None),
745 make_member("eng-1", RoleType::Engineer, Some("manager")),
746 ];
747 let states = HashMap::from([("eng-1".to_string(), MemberState::Idle)]);
748
749 let report = generate_board_aware_standup_for(
750 &members[0],
751 &members,
752 &HashMap::new(),
753 &states,
754 5,
755 Some(&missing_board_dir),
756 &HashMap::new(),
757 );
758
759 assert!(report.contains("[eng-1] status: idle"));
760 assert!(!report.contains("assigned tasks:"));
761 assert!(!report.contains("Workflow signals:"));
762 assert!(!report.contains("warning: idle while runnable work exists on the board"));
763 }
764
765 #[test]
766 fn board_aware_standup_prepends_review_policy_context() {
767 let tmp = tempfile::tempdir().unwrap();
768 let team_config_dir = tmp.path().join(".batty").join("team_config");
769 let board_dir = team_config_dir.join("board");
770 std::fs::create_dir_all(&board_dir).unwrap();
771 std::fs::write(
772 team_config_dir.join("review_policy.md"),
773 "Approve only after tests pass.",
774 )
775 .unwrap();
776
777 let members = vec![
778 make_member("manager", RoleType::Manager, None),
779 make_member("eng-1", RoleType::Engineer, Some("manager")),
780 ];
781 let states = HashMap::from([("eng-1".to_string(), MemberState::Idle)]);
782
783 let report = generate_board_aware_standup_for(
784 &members[0],
785 &members,
786 &HashMap::new(),
787 &states,
788 5,
789 Some(&board_dir),
790 &HashMap::new(),
791 );
792
793 assert!(report.starts_with("Review policy context:\nApprove only after tests pass."));
794 assert!(report.contains("=== STANDUP for manager ==="));
795 }
796
797 #[test]
798 fn board_aware_standup_reloads_updated_review_policy_contents() {
799 let tmp = tempfile::tempdir().unwrap();
800 let team_config_dir = tmp.path().join(".batty").join("team_config");
801 let board_dir = team_config_dir.join("board");
802 std::fs::create_dir_all(&board_dir).unwrap();
803 let policy_path = team_config_dir.join("review_policy.md");
804 std::fs::write(&policy_path, "Initial policy").unwrap();
805
806 let members = vec![
807 make_member("manager", RoleType::Manager, None),
808 make_member("eng-1", RoleType::Engineer, Some("manager")),
809 ];
810 let states = HashMap::from([("eng-1".to_string(), MemberState::Idle)]);
811
812 let first = generate_board_aware_standup_for(
813 &members[0],
814 &members,
815 &HashMap::new(),
816 &states,
817 5,
818 Some(&board_dir),
819 &HashMap::new(),
820 );
821 std::fs::write(&policy_path, "Updated policy").unwrap();
822 let second = generate_board_aware_standup_for(
823 &members[0],
824 &members,
825 &HashMap::new(),
826 &states,
827 5,
828 Some(&board_dir),
829 &HashMap::new(),
830 );
831
832 assert!(first.contains("Initial policy"));
833 assert!(second.contains("Updated policy"));
834 assert!(!second.contains("Initial policy"));
835 }
836
837 #[test]
838 fn write_standup_file_creates_timestamped_markdown_in_batty_dir() {
839 let tmp = tempfile::tempdir().unwrap();
840 let report = "=== STANDUP for user ===\n[architect] status: working\n";
841 let expected_dir = tmp.path().join(".batty").join("standups");
842
843 let path = write_standup_file(tmp.path(), report).unwrap();
844
845 assert_eq!(path.parent(), Some(expected_dir.as_path()));
846 assert_eq!(path.extension().and_then(|ext| ext.to_str()), Some("md"));
847 assert_eq!(std::fs::read_to_string(&path).unwrap(), report);
848 }
849
850 #[test]
851 fn update_timer_for_state_pauses_while_working_and_restarts_on_idle() {
852 let member = make_member("manager", RoleType::Manager, None);
853 let role = RoleDef {
854 name: "manager".to_string(),
855 role_type: RoleType::Manager,
856 agent: Some("claude".to_string()),
857 instances: 1,
858 prompt: None,
859 talks_to: vec![],
860 channel: None,
861 channel_config: None,
862 nudge_interval_secs: None,
863 receives_standup: Some(true),
864 standup_interval_secs: Some(600),
865 owns: Vec::new(),
866 use_worktrees: false,
867 };
868 let team_config = TeamConfig {
869 name: "test".to_string(),
870 agent: None,
871 workflow_mode: WorkflowMode::Legacy,
872 workflow_policy: WorkflowPolicy::default(),
873 board: BoardConfig::default(),
874 standup: StandupConfig::default(),
875 automation: AutomationConfig::default(),
876 automation_sender: None,
877 external_senders: Vec::new(),
878 orchestrator_pane: true,
879 orchestrator_position: OrchestratorPosition::Bottom,
880 layout: None,
881 cost: Default::default(),
882 event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
883 retro_min_duration_secs: 60,
884 roles: vec![role],
885 };
886 let members = vec![member];
887 let mut paused_standups = HashSet::new();
888 let mut last_standup = HashMap::from([(
889 "manager".to_string(),
890 Instant::now() - Duration::from_secs(120),
891 )]);
892
893 update_timer_for_state(
894 &team_config,
895 &members,
896 &mut paused_standups,
897 &mut last_standup,
898 "manager",
899 MemberState::Working,
900 );
901
902 assert!(paused_standups.contains("manager"));
903 assert!(!last_standup.contains_key("manager"));
904
905 update_timer_for_state(
906 &team_config,
907 &members,
908 &mut paused_standups,
909 &mut last_standup,
910 "manager",
911 MemberState::Idle,
912 );
913
914 assert!(!paused_standups.contains("manager"));
915 assert!(last_standup["manager"].elapsed() < Duration::from_secs(1));
916 }
917
918 #[test]
919 fn maybe_generate_standup_skips_when_global_interval_is_zero() {
920 let tmp = tempfile::tempdir().unwrap();
921 let member = make_member("manager", RoleType::Manager, None);
922 let role = RoleDef {
923 name: "manager".to_string(),
924 role_type: RoleType::Manager,
925 agent: Some("claude".to_string()),
926 instances: 1,
927 prompt: None,
928 talks_to: vec![],
929 channel: None,
930 channel_config: None,
931 nudge_interval_secs: None,
932 receives_standup: Some(true),
933 standup_interval_secs: Some(600),
934 owns: Vec::new(),
935 use_worktrees: false,
936 };
937 let team_config = TeamConfig {
938 name: "test".to_string(),
939 agent: None,
940 workflow_mode: WorkflowMode::Legacy,
941 workflow_policy: WorkflowPolicy::default(),
942 board: BoardConfig::default(),
943 standup: StandupConfig {
944 interval_secs: 0,
945 output_lines: 30,
946 },
947 automation: AutomationConfig::default(),
948 automation_sender: None,
949 external_senders: Vec::new(),
950 orchestrator_pane: false,
951 orchestrator_position: OrchestratorPosition::Bottom,
952 layout: None,
953 cost: Default::default(),
954 event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
955 retro_min_duration_secs: 60,
956 roles: vec![role],
957 };
958 let members = vec![member];
959 let mut last_standup = HashMap::new();
960
961 let generated = maybe_generate_standup(StandupGenerationContext {
962 project_root: tmp.path(),
963 team_config: &team_config,
964 members: &members,
965 watchers: &HashMap::new(),
966 states: &HashMap::new(),
967 pane_map: &HashMap::new(),
968 telegram_bot: None,
969 paused_standups: &HashSet::new(),
970 last_standup: &mut last_standup,
971 backend_health: &HashMap::new(),
972 })
973 .unwrap();
974
975 assert!(generated.is_empty());
976 assert!(last_standup.is_empty());
977 }
978
979 #[test]
980 fn maybe_generate_standup_writes_user_report_to_file_without_telegram_bot() {
981 let tmp = tempfile::tempdir().unwrap();
982 let user = MemberInstance {
983 name: "user".to_string(),
984 role_name: "user".to_string(),
985 role_type: RoleType::User,
986 agent: None,
987 prompt: None,
988 reports_to: None,
989 use_worktrees: false,
990 };
991 let architect = MemberInstance {
992 name: "architect".to_string(),
993 role_name: "architect".to_string(),
994 role_type: RoleType::Architect,
995 agent: Some("claude".to_string()),
996 prompt: None,
997 reports_to: Some("user".to_string()),
998 use_worktrees: false,
999 };
1000 let user_role = RoleDef {
1001 name: "user".to_string(),
1002 role_type: RoleType::User,
1003 agent: None,
1004 instances: 1,
1005 prompt: None,
1006 talks_to: vec!["architect".to_string()],
1007 channel: None,
1008 channel_config: None,
1009 nudge_interval_secs: None,
1010 receives_standup: Some(true),
1011 standup_interval_secs: Some(1),
1012 owns: Vec::new(),
1013 use_worktrees: false,
1014 };
1015 let architect_role = RoleDef {
1016 name: "architect".to_string(),
1017 role_type: RoleType::Architect,
1018 agent: Some("claude".to_string()),
1019 instances: 1,
1020 prompt: None,
1021 talks_to: vec![],
1022 channel: None,
1023 channel_config: None,
1024 nudge_interval_secs: None,
1025 receives_standup: Some(false),
1026 standup_interval_secs: None,
1027 owns: Vec::new(),
1028 use_worktrees: false,
1029 };
1030 let team_config = TeamConfig {
1031 name: "test".to_string(),
1032 agent: None,
1033 workflow_mode: WorkflowMode::Legacy,
1034 workflow_policy: WorkflowPolicy::default(),
1035 board: BoardConfig::default(),
1036 standup: StandupConfig {
1037 interval_secs: 1,
1038 output_lines: 30,
1039 },
1040 automation: AutomationConfig::default(),
1041 automation_sender: None,
1042 external_senders: Vec::new(),
1043 orchestrator_pane: false,
1044 orchestrator_position: OrchestratorPosition::Bottom,
1045 layout: None,
1046 cost: Default::default(),
1047 event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1048 retro_min_duration_secs: 60,
1049 roles: vec![user_role, architect_role],
1050 };
1051 let members = vec![user.clone(), architect];
1052 let states = HashMap::from([("architect".to_string(), MemberState::Working)]);
1053 let mut last_standup =
1054 HashMap::from([(user.name.clone(), Instant::now() - Duration::from_secs(5))]);
1055
1056 let generated = maybe_generate_standup(StandupGenerationContext {
1057 project_root: tmp.path(),
1058 team_config: &team_config,
1059 members: &members,
1060 watchers: &HashMap::new(),
1061 states: &states,
1062 pane_map: &HashMap::new(),
1063 telegram_bot: None,
1064 paused_standups: &HashSet::new(),
1065 last_standup: &mut last_standup,
1066 backend_health: &HashMap::new(),
1067 })
1068 .unwrap();
1069
1070 assert_eq!(generated, vec!["user".to_string()]);
1071
1072 let standups_dir = tmp.path().join(".batty").join("standups");
1073 let entries = std::fs::read_dir(&standups_dir)
1074 .unwrap()
1075 .collect::<std::io::Result<Vec<_>>>()
1076 .unwrap();
1077 assert_eq!(entries.len(), 1);
1078
1079 let report = std::fs::read_to_string(entries[0].path()).unwrap();
1080 assert!(report.contains("=== STANDUP for user ==="));
1081 assert!(report.contains("[architect] status: working"));
1082 }
1083
1084 #[test]
1085 fn standup_includes_backend_health_warning_for_unhealthy_agent() {
1086 let manager = make_member("manager", RoleType::Manager, None);
1087 let eng = make_member("eng-1", RoleType::Engineer, Some("manager"));
1088 let members = vec![manager.clone(), eng.clone()];
1089 let states = HashMap::new();
1090
1091 let mut backend_health = HashMap::new();
1092 backend_health.insert(
1093 "eng-1".to_string(),
1094 crate::agent::BackendHealth::Unreachable,
1095 );
1096
1097 let report = generate_board_aware_standup_for(
1098 &manager,
1099 &members,
1100 &HashMap::new(),
1101 &states,
1102 5,
1103 None,
1104 &backend_health,
1105 );
1106
1107 assert!(
1108 report.contains("backend: unreachable"),
1109 "standup should warn about unhealthy backend: {report}"
1110 );
1111 }
1112
1113 #[test]
1114 fn standup_omits_backend_health_when_healthy() {
1115 let manager = make_member("manager", RoleType::Manager, None);
1116 let eng = make_member("eng-1", RoleType::Engineer, Some("manager"));
1117 let members = vec![manager.clone(), eng.clone()];
1118 let states = HashMap::new();
1119
1120 let mut backend_health = HashMap::new();
1121 backend_health.insert("eng-1".to_string(), crate::agent::BackendHealth::Healthy);
1122
1123 let report = generate_board_aware_standup_for(
1124 &manager,
1125 &members,
1126 &HashMap::new(),
1127 &states,
1128 5,
1129 None,
1130 &backend_health,
1131 );
1132
1133 assert!(
1134 !report.contains("backend:"),
1135 "standup should not mention backend when healthy: {report}"
1136 );
1137 }
1138
1139 #[test]
1142 fn standup_shows_degraded_backend_health() {
1143 let manager = make_member("manager", RoleType::Manager, None);
1144 let eng = make_member("eng-1", RoleType::Engineer, Some("manager"));
1145 let members = vec![manager.clone(), eng];
1146 let mut backend_health = HashMap::new();
1147 backend_health.insert("eng-1".to_string(), crate::agent::BackendHealth::Degraded);
1148
1149 let report = generate_board_aware_standup_for(
1150 &manager,
1151 &members,
1152 &HashMap::new(),
1153 &HashMap::new(),
1154 5,
1155 None,
1156 &backend_health,
1157 );
1158
1159 assert!(
1160 report.contains("backend: degraded"),
1161 "standup should warn about degraded backend: {report}"
1162 );
1163 }
1164
1165 #[test]
1166 fn standup_default_state_is_idle() {
1167 let manager = make_member("manager", RoleType::Manager, None);
1168 let eng = make_member("eng-1", RoleType::Engineer, Some("manager"));
1169 let members = vec![manager.clone(), eng];
1170 let report = generate_standup_for(&manager, &members, &HashMap::new(), &HashMap::new(), 5);
1172 assert!(report.contains("[eng-1] status: idle"));
1173 }
1174
1175 #[test]
1176 fn board_aware_standup_all_done_tasks_shows_none_assigned() {
1177 let tmp = tempfile::tempdir().unwrap();
1178 let board_dir = tmp.path().join(".batty").join("team_config").join("board");
1179 write_task(&board_dir, 10, "finished", "done", Some("eng-1"), None);
1181
1182 let members = vec![
1183 make_member("manager", RoleType::Manager, None),
1184 make_member("eng-1", RoleType::Engineer, Some("manager")),
1185 ];
1186 let states = HashMap::from([("eng-1".to_string(), MemberState::Working)]);
1187
1188 let report = generate_board_aware_standup_for(
1189 &members[0],
1190 &members,
1191 &HashMap::new(),
1192 &states,
1193 5,
1194 Some(&board_dir),
1195 &HashMap::new(),
1196 );
1197
1198 assert!(report.contains("assigned tasks: none"));
1199 }
1200
1201 #[test]
1202 fn board_aware_standup_multiple_tasks_sorted_ascending() {
1203 let tmp = tempfile::tempdir().unwrap();
1204 let board_dir = tmp.path().join(".batty").join("team_config").join("board");
1205 write_task(&board_dir, 5, "second", "in-progress", Some("eng-1"), None);
1206 write_task(&board_dir, 2, "first", "in-progress", Some("eng-1"), None);
1207 write_task(&board_dir, 9, "third", "review", Some("eng-1"), None);
1208
1209 let members = vec![
1210 make_member("manager", RoleType::Manager, None),
1211 make_member("eng-1", RoleType::Engineer, Some("manager")),
1212 ];
1213
1214 let report = generate_board_aware_standup_for(
1215 &members[0],
1216 &members,
1217 &HashMap::new(),
1218 &HashMap::new(),
1219 5,
1220 Some(&board_dir),
1221 &HashMap::new(),
1222 );
1223
1224 assert!(report.contains("assigned tasks: #2, #5, #9"));
1225 }
1226
1227 #[test]
1228 fn board_aware_standup_no_idle_warning_when_no_runnable_work() {
1229 let tmp = tempfile::tempdir().unwrap();
1230 let board_dir = tmp.path().join(".batty").join("team_config").join("board");
1231 write_task(&board_dir, 1, "active", "in-progress", Some("eng-1"), None);
1233 write_task(&board_dir, 2, "done-task", "done", Some("eng-2"), None);
1234
1235 let members = vec![
1236 make_member("manager", RoleType::Manager, None),
1237 make_member("eng-1", RoleType::Engineer, Some("manager")),
1238 make_member("eng-2", RoleType::Engineer, Some("manager")),
1239 ];
1240 let states = HashMap::from([("eng-2".to_string(), MemberState::Idle)]);
1241
1242 let report = generate_board_aware_standup_for(
1243 &members[0],
1244 &members,
1245 &HashMap::new(),
1246 &states,
1247 5,
1248 Some(&board_dir),
1249 &HashMap::new(),
1250 );
1251
1252 assert!(!report.contains("warning: idle while runnable work exists"));
1253 }
1254
1255 #[test]
1256 fn board_aware_standup_review_pipeline_metrics_when_present() {
1257 let tmp = tempfile::tempdir().unwrap();
1258 let board_dir = tmp.path().join(".batty").join("team_config").join("board");
1259 write_task(&board_dir, 1, "in-review", "review", Some("eng-1"), None);
1261 write_task(&board_dir, 2, "ready", "todo", None, None);
1263
1264 let members = vec![
1265 make_member("manager", RoleType::Manager, None),
1266 make_member("eng-1", RoleType::Engineer, Some("manager")),
1267 ];
1268
1269 let report = generate_board_aware_standup_for(
1270 &members[0],
1271 &members,
1272 &HashMap::new(),
1273 &HashMap::new(),
1274 5,
1275 Some(&board_dir),
1276 &HashMap::new(),
1277 );
1278
1279 assert!(report.contains("Workflow signals:"));
1280 assert!(report.contains("blocked tasks: 0"));
1281 assert!(report.contains("oldest review age:"));
1282 }
1283
1284 #[test]
1285 fn format_assigned_task_ids_empty_vec() {
1286 let ids: Vec<u32> = vec![];
1287 assert_eq!(format_assigned_task_ids(Some(&ids)), "none");
1288 }
1289
1290 #[test]
1291 fn format_assigned_task_ids_none() {
1292 assert_eq!(format_assigned_task_ids(None), "none");
1293 }
1294
1295 #[test]
1296 fn format_assigned_task_ids_single() {
1297 let ids = vec![42];
1298 assert_eq!(format_assigned_task_ids(Some(&ids)), "#42");
1299 }
1300
1301 #[test]
1302 fn format_age_with_value() {
1303 assert_eq!(format_age(Some(120)), "120s");
1304 }
1305
1306 #[test]
1307 fn format_age_none() {
1308 assert_eq!(format_age(None), "n/a");
1309 }
1310
1311 #[test]
1312 fn project_root_from_board_dir_valid_path() {
1313 let root = Path::new("/project");
1314 let board_dir = root.join(".batty").join("team_config").join("board");
1315 assert_eq!(project_root_from_board_dir(Some(&board_dir)), Some(root));
1316 }
1317
1318 #[test]
1319 fn project_root_from_board_dir_invalid_structure() {
1320 let bad_path = Path::new("/some/random/path");
1321 assert_eq!(project_root_from_board_dir(Some(bad_path)), None);
1322 }
1323
1324 #[test]
1325 fn project_root_from_board_dir_none() {
1326 assert_eq!(project_root_from_board_dir(None), None);
1327 }
1328
1329 #[test]
1332 fn snapshot_and_restore_timer_roundtrip() {
1333 let mut timers = HashMap::new();
1334 timers.insert(
1335 "manager".to_string(),
1336 Instant::now() - Duration::from_secs(30),
1337 );
1338 timers.insert(
1339 "architect".to_string(),
1340 Instant::now() - Duration::from_secs(120),
1341 );
1342
1343 let snapshot = snapshot_timer_state(&timers);
1344 assert!(snapshot["manager"] >= 30);
1345 assert!(snapshot["architect"] >= 120);
1346
1347 let restored = restore_timer_state(snapshot);
1348 assert!(restored["manager"].elapsed().as_secs() >= 30);
1350 assert!(restored["architect"].elapsed().as_secs() >= 120);
1351 }
1352
1353 #[test]
1354 fn update_timer_for_non_standup_member_clears_state() {
1355 let eng = make_member("eng-1", RoleType::Engineer, None);
1356 let role = RoleDef {
1357 name: "eng-1".to_string(),
1358 role_type: RoleType::Engineer,
1359 agent: Some("claude".to_string()),
1360 instances: 1,
1361 prompt: None,
1362 talks_to: vec![],
1363 channel: None,
1364 channel_config: None,
1365 nudge_interval_secs: None,
1366 receives_standup: Some(false),
1367 standup_interval_secs: None,
1368 owns: Vec::new(),
1369 use_worktrees: false,
1370 };
1371 let team_config = TeamConfig {
1372 name: "test".to_string(),
1373 agent: None,
1374 workflow_mode: WorkflowMode::Legacy,
1375 workflow_policy: WorkflowPolicy::default(),
1376 board: BoardConfig::default(),
1377 standup: StandupConfig::default(),
1378 automation: AutomationConfig::default(),
1379 automation_sender: None,
1380 external_senders: Vec::new(),
1381 orchestrator_pane: false,
1382 orchestrator_position: OrchestratorPosition::Bottom,
1383 layout: None,
1384 cost: Default::default(),
1385 event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1386 retro_min_duration_secs: 60,
1387 roles: vec![role],
1388 };
1389 let members = vec![eng];
1390 let mut paused = HashSet::from(["eng-1".to_string()]);
1391 let mut last = HashMap::from([("eng-1".to_string(), Instant::now())]);
1392
1393 update_timer_for_state(
1394 &team_config,
1395 &members,
1396 &mut paused,
1397 &mut last,
1398 "eng-1",
1399 MemberState::Idle,
1400 );
1401
1402 assert!(!paused.contains("eng-1"));
1403 assert!(!last.contains_key("eng-1"));
1404 }
1405
1406 #[test]
1407 fn standup_interval_for_manager_uses_role_override() {
1408 let member = make_member("manager", RoleType::Manager, None);
1409 let role = RoleDef {
1410 name: "manager".to_string(),
1411 role_type: RoleType::Manager,
1412 agent: Some("claude".to_string()),
1413 instances: 1,
1414 prompt: None,
1415 talks_to: vec![],
1416 channel: None,
1417 channel_config: None,
1418 nudge_interval_secs: None,
1419 receives_standup: Some(true),
1420 standup_interval_secs: Some(300),
1421 owns: Vec::new(),
1422 use_worktrees: false,
1423 };
1424 let team_config = TeamConfig {
1425 name: "test".to_string(),
1426 agent: None,
1427 workflow_mode: WorkflowMode::Legacy,
1428 workflow_policy: WorkflowPolicy::default(),
1429 board: BoardConfig::default(),
1430 standup: StandupConfig {
1431 interval_secs: 600,
1432 output_lines: 30,
1433 },
1434 automation: AutomationConfig::default(),
1435 automation_sender: None,
1436 external_senders: Vec::new(),
1437 orchestrator_pane: false,
1438 orchestrator_position: OrchestratorPosition::Bottom,
1439 layout: None,
1440 cost: Default::default(),
1441 event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1442 retro_min_duration_secs: 60,
1443 roles: vec![role],
1444 };
1445 let members = vec![member];
1446
1447 let interval = standup_interval_for_member_name(&team_config, &members, "manager");
1448 assert_eq!(interval, Some(Duration::from_secs(300)));
1449 }
1450
1451 #[test]
1452 fn standup_interval_for_manager_falls_back_to_global() {
1453 let member = make_member("manager", RoleType::Manager, None);
1454 let role = RoleDef {
1455 name: "manager".to_string(),
1456 role_type: RoleType::Manager,
1457 agent: Some("claude".to_string()),
1458 instances: 1,
1459 prompt: None,
1460 talks_to: vec![],
1461 channel: None,
1462 channel_config: None,
1463 nudge_interval_secs: None,
1464 receives_standup: None, standup_interval_secs: None, owns: Vec::new(),
1467 use_worktrees: false,
1468 };
1469 let team_config = TeamConfig {
1470 name: "test".to_string(),
1471 agent: None,
1472 workflow_mode: WorkflowMode::Legacy,
1473 workflow_policy: WorkflowPolicy::default(),
1474 board: BoardConfig::default(),
1475 standup: StandupConfig {
1476 interval_secs: 900,
1477 output_lines: 30,
1478 },
1479 automation: AutomationConfig::default(),
1480 automation_sender: None,
1481 external_senders: Vec::new(),
1482 orchestrator_pane: false,
1483 orchestrator_position: OrchestratorPosition::Bottom,
1484 layout: None,
1485 cost: Default::default(),
1486 event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1487 retro_min_duration_secs: 60,
1488 roles: vec![role],
1489 };
1490 let members = vec![member];
1491
1492 let interval = standup_interval_for_member_name(&team_config, &members, "manager");
1493 assert_eq!(interval, Some(Duration::from_secs(900)));
1494 }
1495
1496 #[test]
1497 fn standup_interval_for_engineer_returns_none() {
1498 let member = make_member("eng-1", RoleType::Engineer, Some("manager"));
1499 let role = RoleDef {
1500 name: "eng-1".to_string(),
1501 role_type: RoleType::Engineer,
1502 agent: Some("claude".to_string()),
1503 instances: 1,
1504 prompt: None,
1505 talks_to: vec![],
1506 channel: None,
1507 channel_config: None,
1508 nudge_interval_secs: None,
1509 receives_standup: None, standup_interval_secs: None,
1511 owns: Vec::new(),
1512 use_worktrees: false,
1513 };
1514 let team_config = TeamConfig {
1515 name: "test".to_string(),
1516 agent: None,
1517 workflow_mode: WorkflowMode::Legacy,
1518 workflow_policy: WorkflowPolicy::default(),
1519 board: BoardConfig::default(),
1520 standup: StandupConfig::default(),
1521 automation: AutomationConfig::default(),
1522 automation_sender: None,
1523 external_senders: Vec::new(),
1524 orchestrator_pane: false,
1525 orchestrator_position: OrchestratorPosition::Bottom,
1526 layout: None,
1527 cost: Default::default(),
1528 event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1529 retro_min_duration_secs: 60,
1530 roles: vec![role],
1531 };
1532 let members = vec![member];
1533
1534 let interval = standup_interval_for_member_name(&team_config, &members, "eng-1");
1535 assert_eq!(interval, None);
1536 }
1537
1538 #[test]
1539 fn standup_interval_for_unknown_member_returns_none() {
1540 let team_config = TeamConfig {
1541 name: "test".to_string(),
1542 agent: None,
1543 workflow_mode: WorkflowMode::Legacy,
1544 workflow_policy: WorkflowPolicy::default(),
1545 board: BoardConfig::default(),
1546 standup: StandupConfig::default(),
1547 automation: AutomationConfig::default(),
1548 automation_sender: None,
1549 external_senders: Vec::new(),
1550 orchestrator_pane: false,
1551 orchestrator_position: OrchestratorPosition::Bottom,
1552 layout: None,
1553 cost: Default::default(),
1554 event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1555 retro_min_duration_secs: 60,
1556 roles: vec![],
1557 };
1558
1559 let interval = standup_interval_for_member_name(&team_config, &[], "nobody");
1560 assert_eq!(interval, None);
1561 }
1562
1563 #[test]
1564 fn maybe_generate_standup_skips_when_standups_disabled() {
1565 let tmp = tempfile::tempdir().unwrap();
1566 let member = make_member("manager", RoleType::Manager, None);
1567 let role = RoleDef {
1568 name: "manager".to_string(),
1569 role_type: RoleType::Manager,
1570 agent: Some("claude".to_string()),
1571 instances: 1,
1572 prompt: None,
1573 talks_to: vec![],
1574 channel: None,
1575 channel_config: None,
1576 nudge_interval_secs: None,
1577 receives_standup: Some(true),
1578 standup_interval_secs: Some(60),
1579 owns: Vec::new(),
1580 use_worktrees: false,
1581 };
1582 let team_config = TeamConfig {
1583 name: "test".to_string(),
1584 agent: None,
1585 workflow_mode: WorkflowMode::Legacy,
1586 workflow_policy: WorkflowPolicy::default(),
1587 board: BoardConfig::default(),
1588 standup: StandupConfig {
1589 interval_secs: 60,
1590 output_lines: 30,
1591 },
1592 automation: AutomationConfig {
1593 standups: false,
1594 ..AutomationConfig::default()
1595 },
1596 automation_sender: None,
1597 external_senders: Vec::new(),
1598 orchestrator_pane: false,
1599 orchestrator_position: OrchestratorPosition::Bottom,
1600 layout: None,
1601 cost: Default::default(),
1602 event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1603 retro_min_duration_secs: 60,
1604 roles: vec![role],
1605 };
1606 let members = vec![member];
1607 let mut last_standup = HashMap::new();
1608
1609 let generated = maybe_generate_standup(StandupGenerationContext {
1610 project_root: tmp.path(),
1611 team_config: &team_config,
1612 members: &members,
1613 watchers: &HashMap::new(),
1614 states: &HashMap::new(),
1615 pane_map: &HashMap::new(),
1616 telegram_bot: None,
1617 paused_standups: &HashSet::new(),
1618 last_standup: &mut last_standup,
1619 backend_health: &HashMap::new(),
1620 })
1621 .unwrap();
1622
1623 assert!(generated.is_empty());
1624 }
1625
1626 #[test]
1627 fn maybe_generate_standup_skips_paused_recipients() {
1628 let tmp = tempfile::tempdir().unwrap();
1629 let member = make_member("manager", RoleType::Manager, None);
1630 let eng = make_member("eng-1", RoleType::Engineer, Some("manager"));
1631 let role = RoleDef {
1632 name: "manager".to_string(),
1633 role_type: RoleType::Manager,
1634 agent: Some("claude".to_string()),
1635 instances: 1,
1636 prompt: None,
1637 talks_to: vec![],
1638 channel: None,
1639 channel_config: None,
1640 nudge_interval_secs: None,
1641 receives_standup: Some(true),
1642 standup_interval_secs: Some(1),
1643 owns: Vec::new(),
1644 use_worktrees: false,
1645 };
1646 let eng_role = RoleDef {
1647 name: "eng-1".to_string(),
1648 role_type: RoleType::Engineer,
1649 agent: Some("claude".to_string()),
1650 instances: 1,
1651 prompt: None,
1652 talks_to: vec![],
1653 channel: None,
1654 channel_config: None,
1655 nudge_interval_secs: None,
1656 receives_standup: Some(false),
1657 standup_interval_secs: None,
1658 owns: Vec::new(),
1659 use_worktrees: false,
1660 };
1661 let team_config = TeamConfig {
1662 name: "test".to_string(),
1663 agent: None,
1664 workflow_mode: WorkflowMode::Legacy,
1665 workflow_policy: WorkflowPolicy::default(),
1666 board: BoardConfig::default(),
1667 standup: StandupConfig {
1668 interval_secs: 1,
1669 output_lines: 30,
1670 },
1671 automation: AutomationConfig::default(),
1672 automation_sender: None,
1673 external_senders: Vec::new(),
1674 orchestrator_pane: false,
1675 orchestrator_position: OrchestratorPosition::Bottom,
1676 layout: None,
1677 cost: Default::default(),
1678 event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1679 retro_min_duration_secs: 60,
1680 roles: vec![role, eng_role],
1681 };
1682 let members = vec![member, eng];
1683 let paused = HashSet::from(["manager".to_string()]);
1684 let mut last_standup = HashMap::from([(
1685 "manager".to_string(),
1686 Instant::now() - Duration::from_secs(100),
1687 )]);
1688
1689 let generated = maybe_generate_standup(StandupGenerationContext {
1690 project_root: tmp.path(),
1691 team_config: &team_config,
1692 members: &members,
1693 watchers: &HashMap::new(),
1694 states: &HashMap::new(),
1695 pane_map: &HashMap::new(),
1696 telegram_bot: None,
1697 paused_standups: &paused,
1698 last_standup: &mut last_standup,
1699 backend_health: &HashMap::new(),
1700 })
1701 .unwrap();
1702
1703 assert!(generated.is_empty());
1704 }
1705
1706 #[test]
1707 fn maybe_generate_standup_first_call_seeds_timer_without_generating() {
1708 let tmp = tempfile::tempdir().unwrap();
1709 let member = make_member("manager", RoleType::Manager, None);
1710 let eng = make_member("eng-1", RoleType::Engineer, Some("manager"));
1711 let role = RoleDef {
1712 name: "manager".to_string(),
1713 role_type: RoleType::Manager,
1714 agent: Some("claude".to_string()),
1715 instances: 1,
1716 prompt: None,
1717 talks_to: vec![],
1718 channel: None,
1719 channel_config: None,
1720 nudge_interval_secs: None,
1721 receives_standup: Some(true),
1722 standup_interval_secs: Some(1),
1723 owns: Vec::new(),
1724 use_worktrees: false,
1725 };
1726 let eng_role = RoleDef {
1727 name: "eng-1".to_string(),
1728 role_type: RoleType::Engineer,
1729 agent: Some("claude".to_string()),
1730 instances: 1,
1731 prompt: None,
1732 talks_to: vec![],
1733 channel: None,
1734 channel_config: None,
1735 nudge_interval_secs: None,
1736 receives_standup: Some(false),
1737 standup_interval_secs: None,
1738 owns: Vec::new(),
1739 use_worktrees: false,
1740 };
1741 let team_config = TeamConfig {
1742 name: "test".to_string(),
1743 agent: None,
1744 workflow_mode: WorkflowMode::Legacy,
1745 workflow_policy: WorkflowPolicy::default(),
1746 board: BoardConfig::default(),
1747 standup: StandupConfig {
1748 interval_secs: 1,
1749 output_lines: 30,
1750 },
1751 automation: AutomationConfig::default(),
1752 automation_sender: None,
1753 external_senders: Vec::new(),
1754 orchestrator_pane: false,
1755 orchestrator_position: OrchestratorPosition::Bottom,
1756 layout: None,
1757 cost: Default::default(),
1758 event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1759 retro_min_duration_secs: 60,
1760 roles: vec![role, eng_role],
1761 };
1762 let members = vec![member, eng];
1763 let mut last_standup = HashMap::new(); let generated = maybe_generate_standup(StandupGenerationContext {
1766 project_root: tmp.path(),
1767 team_config: &team_config,
1768 members: &members,
1769 watchers: &HashMap::new(),
1770 states: &HashMap::new(),
1771 pane_map: &HashMap::new(),
1772 telegram_bot: None,
1773 paused_standups: &HashSet::new(),
1774 last_standup: &mut last_standup,
1775 backend_health: &HashMap::new(),
1776 })
1777 .unwrap();
1778
1779 assert!(generated.is_empty());
1781 assert!(last_standup.contains_key("manager"));
1782 }
1783}