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