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 use_sdk_mode: false,
881 auto_respawn_on_crash: false,
882 shim_health_check_interval_secs: 60,
883 shim_health_timeout_secs: 120,
884 shim_shutdown_timeout_secs: 30,
885 shim_working_state_timeout_secs: 1800,
886 pending_queue_max_age_secs: 600,
887 event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
888 retro_min_duration_secs: 60,
889 roles: vec![role],
890 };
891 let members = vec![member];
892 let mut paused_standups = HashSet::new();
893 let mut last_standup = HashMap::from([(
894 "manager".to_string(),
895 Instant::now() - Duration::from_secs(120),
896 )]);
897
898 update_timer_for_state(
899 &team_config,
900 &members,
901 &mut paused_standups,
902 &mut last_standup,
903 "manager",
904 MemberState::Working,
905 );
906
907 assert!(paused_standups.contains("manager"));
908 assert!(!last_standup.contains_key("manager"));
909
910 update_timer_for_state(
911 &team_config,
912 &members,
913 &mut paused_standups,
914 &mut last_standup,
915 "manager",
916 MemberState::Idle,
917 );
918
919 assert!(!paused_standups.contains("manager"));
920 assert!(last_standup["manager"].elapsed() < Duration::from_secs(1));
921 }
922
923 #[test]
924 fn maybe_generate_standup_skips_when_global_interval_is_zero() {
925 let tmp = tempfile::tempdir().unwrap();
926 let member = make_member("manager", RoleType::Manager, None);
927 let role = RoleDef {
928 name: "manager".to_string(),
929 role_type: RoleType::Manager,
930 agent: Some("claude".to_string()),
931 instances: 1,
932 prompt: None,
933 talks_to: vec![],
934 channel: None,
935 channel_config: None,
936 nudge_interval_secs: None,
937 receives_standup: Some(true),
938 standup_interval_secs: Some(600),
939 owns: Vec::new(),
940 use_worktrees: false,
941 };
942 let team_config = TeamConfig {
943 name: "test".to_string(),
944 agent: None,
945 workflow_mode: WorkflowMode::Legacy,
946 workflow_policy: WorkflowPolicy::default(),
947 board: BoardConfig::default(),
948 standup: StandupConfig {
949 interval_secs: 0,
950 output_lines: 30,
951 },
952 automation: AutomationConfig::default(),
953 automation_sender: None,
954 external_senders: Vec::new(),
955 orchestrator_pane: false,
956 orchestrator_position: OrchestratorPosition::Bottom,
957 layout: None,
958 cost: Default::default(),
959 grafana: Default::default(),
960 use_shim: false,
961 use_sdk_mode: false,
962 auto_respawn_on_crash: false,
963 shim_health_check_interval_secs: 60,
964 shim_health_timeout_secs: 120,
965 shim_shutdown_timeout_secs: 30,
966 shim_working_state_timeout_secs: 1800,
967 pending_queue_max_age_secs: 600,
968 event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
969 retro_min_duration_secs: 60,
970 roles: vec![role],
971 };
972 let members = vec![member];
973 let mut last_standup = HashMap::new();
974
975 let generated = maybe_generate_standup(StandupGenerationContext {
976 project_root: tmp.path(),
977 team_config: &team_config,
978 members: &members,
979 watchers: &HashMap::new(),
980 states: &HashMap::new(),
981 pane_map: &HashMap::new(),
982 telegram_bot: None,
983 paused_standups: &HashSet::new(),
984 last_standup: &mut last_standup,
985 backend_health: &HashMap::new(),
986 })
987 .unwrap();
988
989 assert!(generated.is_empty());
990 assert!(last_standup.is_empty());
991 }
992
993 #[test]
994 fn maybe_generate_standup_writes_user_report_to_file_without_telegram_bot() {
995 let tmp = tempfile::tempdir().unwrap();
996 let user = MemberInstance {
997 name: "user".to_string(),
998 role_name: "user".to_string(),
999 role_type: RoleType::User,
1000 agent: None,
1001 prompt: None,
1002 reports_to: None,
1003 use_worktrees: false,
1004 };
1005 let architect = MemberInstance {
1006 name: "architect".to_string(),
1007 role_name: "architect".to_string(),
1008 role_type: RoleType::Architect,
1009 agent: Some("claude".to_string()),
1010 prompt: None,
1011 reports_to: Some("user".to_string()),
1012 use_worktrees: false,
1013 };
1014 let user_role = RoleDef {
1015 name: "user".to_string(),
1016 role_type: RoleType::User,
1017 agent: None,
1018 instances: 1,
1019 prompt: None,
1020 talks_to: vec!["architect".to_string()],
1021 channel: None,
1022 channel_config: None,
1023 nudge_interval_secs: None,
1024 receives_standup: Some(true),
1025 standup_interval_secs: Some(1),
1026 owns: Vec::new(),
1027 use_worktrees: false,
1028 };
1029 let architect_role = RoleDef {
1030 name: "architect".to_string(),
1031 role_type: RoleType::Architect,
1032 agent: Some("claude".to_string()),
1033 instances: 1,
1034 prompt: None,
1035 talks_to: vec![],
1036 channel: None,
1037 channel_config: None,
1038 nudge_interval_secs: None,
1039 receives_standup: Some(false),
1040 standup_interval_secs: None,
1041 owns: Vec::new(),
1042 use_worktrees: false,
1043 };
1044 let team_config = TeamConfig {
1045 name: "test".to_string(),
1046 agent: None,
1047 workflow_mode: WorkflowMode::Legacy,
1048 workflow_policy: WorkflowPolicy::default(),
1049 board: BoardConfig::default(),
1050 standup: StandupConfig {
1051 interval_secs: 1,
1052 output_lines: 30,
1053 },
1054 automation: AutomationConfig::default(),
1055 automation_sender: None,
1056 external_senders: Vec::new(),
1057 orchestrator_pane: false,
1058 orchestrator_position: OrchestratorPosition::Bottom,
1059 layout: None,
1060 cost: Default::default(),
1061 grafana: Default::default(),
1062 use_shim: false,
1063 use_sdk_mode: false,
1064 auto_respawn_on_crash: false,
1065 shim_health_check_interval_secs: 60,
1066 shim_health_timeout_secs: 120,
1067 shim_shutdown_timeout_secs: 30,
1068 shim_working_state_timeout_secs: 1800,
1069 pending_queue_max_age_secs: 600,
1070 event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1071 retro_min_duration_secs: 60,
1072 roles: vec![user_role, architect_role],
1073 };
1074 let members = vec![user.clone(), architect];
1075 let states = HashMap::from([("architect".to_string(), MemberState::Working)]);
1076 let mut last_standup =
1077 HashMap::from([(user.name.clone(), Instant::now() - Duration::from_secs(5))]);
1078
1079 let generated = maybe_generate_standup(StandupGenerationContext {
1080 project_root: tmp.path(),
1081 team_config: &team_config,
1082 members: &members,
1083 watchers: &HashMap::new(),
1084 states: &states,
1085 pane_map: &HashMap::new(),
1086 telegram_bot: None,
1087 paused_standups: &HashSet::new(),
1088 last_standup: &mut last_standup,
1089 backend_health: &HashMap::new(),
1090 })
1091 .unwrap();
1092
1093 assert_eq!(generated, vec!["user".to_string()]);
1094
1095 let standups_dir = tmp.path().join(".batty").join("standups");
1096 let entries = std::fs::read_dir(&standups_dir)
1097 .unwrap()
1098 .collect::<std::io::Result<Vec<_>>>()
1099 .unwrap();
1100 assert_eq!(entries.len(), 1);
1101
1102 let report = std::fs::read_to_string(entries[0].path()).unwrap();
1103 assert!(report.contains("=== STANDUP for user ==="));
1104 assert!(report.contains("[architect] status: working"));
1105 }
1106
1107 #[test]
1108 fn standup_includes_backend_health_warning_for_unhealthy_agent() {
1109 let manager = make_member("manager", RoleType::Manager, None);
1110 let eng = make_member("eng-1", RoleType::Engineer, Some("manager"));
1111 let members = vec![manager.clone(), eng.clone()];
1112 let states = HashMap::new();
1113
1114 let mut backend_health = HashMap::new();
1115 backend_health.insert(
1116 "eng-1".to_string(),
1117 crate::agent::BackendHealth::Unreachable,
1118 );
1119
1120 let report = generate_board_aware_standup_for(
1121 &manager,
1122 &members,
1123 &HashMap::new(),
1124 &states,
1125 5,
1126 None,
1127 &backend_health,
1128 );
1129
1130 assert!(
1131 report.contains("backend: unreachable"),
1132 "standup should warn about unhealthy backend: {report}"
1133 );
1134 }
1135
1136 #[test]
1137 fn standup_omits_backend_health_when_healthy() {
1138 let manager = make_member("manager", RoleType::Manager, None);
1139 let eng = make_member("eng-1", RoleType::Engineer, Some("manager"));
1140 let members = vec![manager.clone(), eng.clone()];
1141 let states = HashMap::new();
1142
1143 let mut backend_health = HashMap::new();
1144 backend_health.insert("eng-1".to_string(), crate::agent::BackendHealth::Healthy);
1145
1146 let report = generate_board_aware_standup_for(
1147 &manager,
1148 &members,
1149 &HashMap::new(),
1150 &states,
1151 5,
1152 None,
1153 &backend_health,
1154 );
1155
1156 assert!(
1157 !report.contains("backend:"),
1158 "standup should not mention backend when healthy: {report}"
1159 );
1160 }
1161
1162 #[test]
1165 fn standup_shows_degraded_backend_health() {
1166 let manager = make_member("manager", RoleType::Manager, None);
1167 let eng = make_member("eng-1", RoleType::Engineer, Some("manager"));
1168 let members = vec![manager.clone(), eng];
1169 let mut backend_health = HashMap::new();
1170 backend_health.insert("eng-1".to_string(), crate::agent::BackendHealth::Degraded);
1171
1172 let report = generate_board_aware_standup_for(
1173 &manager,
1174 &members,
1175 &HashMap::new(),
1176 &HashMap::new(),
1177 5,
1178 None,
1179 &backend_health,
1180 );
1181
1182 assert!(
1183 report.contains("backend: degraded"),
1184 "standup should warn about degraded backend: {report}"
1185 );
1186 }
1187
1188 #[test]
1189 fn standup_default_state_is_idle() {
1190 let manager = make_member("manager", RoleType::Manager, None);
1191 let eng = make_member("eng-1", RoleType::Engineer, Some("manager"));
1192 let members = vec![manager.clone(), eng];
1193 let report = generate_standup_for(&manager, &members, &HashMap::new(), &HashMap::new(), 5);
1195 assert!(report.contains("[eng-1] status: idle"));
1196 }
1197
1198 #[test]
1199 fn board_aware_standup_all_done_tasks_shows_none_assigned() {
1200 let tmp = tempfile::tempdir().unwrap();
1201 let board_dir = tmp.path().join(".batty").join("team_config").join("board");
1202 write_task(&board_dir, 10, "finished", "done", Some("eng-1"), None);
1204
1205 let members = vec![
1206 make_member("manager", RoleType::Manager, None),
1207 make_member("eng-1", RoleType::Engineer, Some("manager")),
1208 ];
1209 let states = HashMap::from([("eng-1".to_string(), MemberState::Working)]);
1210
1211 let report = generate_board_aware_standup_for(
1212 &members[0],
1213 &members,
1214 &HashMap::new(),
1215 &states,
1216 5,
1217 Some(&board_dir),
1218 &HashMap::new(),
1219 );
1220
1221 assert!(report.contains("assigned tasks: none"));
1222 }
1223
1224 #[test]
1225 fn board_aware_standup_multiple_tasks_sorted_ascending() {
1226 let tmp = tempfile::tempdir().unwrap();
1227 let board_dir = tmp.path().join(".batty").join("team_config").join("board");
1228 write_task(&board_dir, 5, "second", "in-progress", Some("eng-1"), None);
1229 write_task(&board_dir, 2, "first", "in-progress", Some("eng-1"), None);
1230 write_task(&board_dir, 9, "third", "review", Some("eng-1"), None);
1231
1232 let members = vec![
1233 make_member("manager", RoleType::Manager, None),
1234 make_member("eng-1", RoleType::Engineer, Some("manager")),
1235 ];
1236
1237 let report = generate_board_aware_standup_for(
1238 &members[0],
1239 &members,
1240 &HashMap::new(),
1241 &HashMap::new(),
1242 5,
1243 Some(&board_dir),
1244 &HashMap::new(),
1245 );
1246
1247 assert!(report.contains("assigned tasks: #2, #5, #9"));
1248 }
1249
1250 #[test]
1251 fn board_aware_standup_no_idle_warning_when_no_runnable_work() {
1252 let tmp = tempfile::tempdir().unwrap();
1253 let board_dir = tmp.path().join(".batty").join("team_config").join("board");
1254 write_task(&board_dir, 1, "active", "in-progress", Some("eng-1"), None);
1256 write_task(&board_dir, 2, "done-task", "done", Some("eng-2"), None);
1257
1258 let members = vec![
1259 make_member("manager", RoleType::Manager, None),
1260 make_member("eng-1", RoleType::Engineer, Some("manager")),
1261 make_member("eng-2", RoleType::Engineer, Some("manager")),
1262 ];
1263 let states = HashMap::from([("eng-2".to_string(), MemberState::Idle)]);
1264
1265 let report = generate_board_aware_standup_for(
1266 &members[0],
1267 &members,
1268 &HashMap::new(),
1269 &states,
1270 5,
1271 Some(&board_dir),
1272 &HashMap::new(),
1273 );
1274
1275 assert!(!report.contains("warning: idle while runnable work exists"));
1276 }
1277
1278 #[test]
1279 fn board_aware_standup_review_pipeline_metrics_when_present() {
1280 let tmp = tempfile::tempdir().unwrap();
1281 let board_dir = tmp.path().join(".batty").join("team_config").join("board");
1282 write_task(&board_dir, 1, "in-review", "review", Some("eng-1"), None);
1284 write_task(&board_dir, 2, "ready", "todo", None, None);
1286
1287 let members = vec![
1288 make_member("manager", RoleType::Manager, None),
1289 make_member("eng-1", RoleType::Engineer, Some("manager")),
1290 ];
1291
1292 let report = generate_board_aware_standup_for(
1293 &members[0],
1294 &members,
1295 &HashMap::new(),
1296 &HashMap::new(),
1297 5,
1298 Some(&board_dir),
1299 &HashMap::new(),
1300 );
1301
1302 assert!(report.contains("Workflow signals:"));
1303 assert!(report.contains("blocked tasks: 0"));
1304 assert!(report.contains("oldest review age:"));
1305 }
1306
1307 #[test]
1308 fn format_assigned_task_ids_empty_vec() {
1309 let ids: Vec<u32> = vec![];
1310 assert_eq!(format_assigned_task_ids(Some(&ids)), "none");
1311 }
1312
1313 #[test]
1314 fn format_assigned_task_ids_none() {
1315 assert_eq!(format_assigned_task_ids(None), "none");
1316 }
1317
1318 #[test]
1319 fn format_assigned_task_ids_single() {
1320 let ids = vec![42];
1321 assert_eq!(format_assigned_task_ids(Some(&ids)), "#42");
1322 }
1323
1324 #[test]
1325 fn format_age_with_value() {
1326 assert_eq!(format_age(Some(120)), "120s");
1327 }
1328
1329 #[test]
1330 fn format_age_none() {
1331 assert_eq!(format_age(None), "n/a");
1332 }
1333
1334 #[test]
1335 fn project_root_from_board_dir_valid_path() {
1336 let root = Path::new("/project");
1337 let board_dir = root.join(".batty").join("team_config").join("board");
1338 assert_eq!(project_root_from_board_dir(Some(&board_dir)), Some(root));
1339 }
1340
1341 #[test]
1342 fn project_root_from_board_dir_invalid_structure() {
1343 let bad_path = Path::new("/some/random/path");
1344 assert_eq!(project_root_from_board_dir(Some(bad_path)), None);
1345 }
1346
1347 #[test]
1348 fn project_root_from_board_dir_none() {
1349 assert_eq!(project_root_from_board_dir(None), None);
1350 }
1351
1352 #[test]
1355 fn snapshot_and_restore_timer_roundtrip() {
1356 let mut timers = HashMap::new();
1357 timers.insert(
1358 "manager".to_string(),
1359 Instant::now() - Duration::from_secs(30),
1360 );
1361 timers.insert(
1362 "architect".to_string(),
1363 Instant::now() - Duration::from_secs(120),
1364 );
1365
1366 let snapshot = snapshot_timer_state(&timers);
1367 assert!(snapshot["manager"] >= 30);
1368 assert!(snapshot["architect"] >= 120);
1369
1370 let restored = restore_timer_state(snapshot);
1371 assert!(restored["manager"].elapsed().as_secs() >= 30);
1373 assert!(restored["architect"].elapsed().as_secs() >= 120);
1374 }
1375
1376 #[test]
1377 fn update_timer_for_non_standup_member_clears_state() {
1378 let eng = make_member("eng-1", RoleType::Engineer, None);
1379 let role = RoleDef {
1380 name: "eng-1".to_string(),
1381 role_type: RoleType::Engineer,
1382 agent: Some("claude".to_string()),
1383 instances: 1,
1384 prompt: None,
1385 talks_to: vec![],
1386 channel: None,
1387 channel_config: None,
1388 nudge_interval_secs: None,
1389 receives_standup: Some(false),
1390 standup_interval_secs: None,
1391 owns: Vec::new(),
1392 use_worktrees: false,
1393 };
1394 let team_config = TeamConfig {
1395 name: "test".to_string(),
1396 agent: None,
1397 workflow_mode: WorkflowMode::Legacy,
1398 workflow_policy: WorkflowPolicy::default(),
1399 board: BoardConfig::default(),
1400 standup: StandupConfig::default(),
1401 automation: AutomationConfig::default(),
1402 automation_sender: None,
1403 external_senders: Vec::new(),
1404 orchestrator_pane: false,
1405 orchestrator_position: OrchestratorPosition::Bottom,
1406 layout: None,
1407 cost: Default::default(),
1408 grafana: Default::default(),
1409 use_shim: false,
1410 use_sdk_mode: false,
1411 auto_respawn_on_crash: false,
1412 shim_health_check_interval_secs: 60,
1413 shim_health_timeout_secs: 120,
1414 shim_shutdown_timeout_secs: 30,
1415 shim_working_state_timeout_secs: 1800,
1416 pending_queue_max_age_secs: 600,
1417 event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1418 retro_min_duration_secs: 60,
1419 roles: vec![role],
1420 };
1421 let members = vec![eng];
1422 let mut paused = HashSet::from(["eng-1".to_string()]);
1423 let mut last = HashMap::from([("eng-1".to_string(), Instant::now())]);
1424
1425 update_timer_for_state(
1426 &team_config,
1427 &members,
1428 &mut paused,
1429 &mut last,
1430 "eng-1",
1431 MemberState::Idle,
1432 );
1433
1434 assert!(!paused.contains("eng-1"));
1435 assert!(!last.contains_key("eng-1"));
1436 }
1437
1438 #[test]
1439 fn standup_interval_for_manager_uses_role_override() {
1440 let member = make_member("manager", RoleType::Manager, None);
1441 let role = RoleDef {
1442 name: "manager".to_string(),
1443 role_type: RoleType::Manager,
1444 agent: Some("claude".to_string()),
1445 instances: 1,
1446 prompt: None,
1447 talks_to: vec![],
1448 channel: None,
1449 channel_config: None,
1450 nudge_interval_secs: None,
1451 receives_standup: Some(true),
1452 standup_interval_secs: Some(300),
1453 owns: Vec::new(),
1454 use_worktrees: false,
1455 };
1456 let team_config = TeamConfig {
1457 name: "test".to_string(),
1458 agent: None,
1459 workflow_mode: WorkflowMode::Legacy,
1460 workflow_policy: WorkflowPolicy::default(),
1461 board: BoardConfig::default(),
1462 standup: StandupConfig {
1463 interval_secs: 600,
1464 output_lines: 30,
1465 },
1466 automation: AutomationConfig::default(),
1467 automation_sender: None,
1468 external_senders: Vec::new(),
1469 orchestrator_pane: false,
1470 orchestrator_position: OrchestratorPosition::Bottom,
1471 layout: None,
1472 cost: Default::default(),
1473 grafana: Default::default(),
1474 use_shim: false,
1475 use_sdk_mode: false,
1476 auto_respawn_on_crash: false,
1477 shim_health_check_interval_secs: 60,
1478 shim_health_timeout_secs: 120,
1479 shim_shutdown_timeout_secs: 30,
1480 shim_working_state_timeout_secs: 1800,
1481 pending_queue_max_age_secs: 600,
1482 event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1483 retro_min_duration_secs: 60,
1484 roles: vec![role],
1485 };
1486 let members = vec![member];
1487
1488 let interval = standup_interval_for_member_name(&team_config, &members, "manager");
1489 assert_eq!(interval, Some(Duration::from_secs(300)));
1490 }
1491
1492 #[test]
1493 fn standup_interval_for_manager_falls_back_to_global() {
1494 let member = make_member("manager", RoleType::Manager, None);
1495 let role = RoleDef {
1496 name: "manager".to_string(),
1497 role_type: RoleType::Manager,
1498 agent: Some("claude".to_string()),
1499 instances: 1,
1500 prompt: None,
1501 talks_to: vec![],
1502 channel: None,
1503 channel_config: None,
1504 nudge_interval_secs: None,
1505 receives_standup: None, standup_interval_secs: None, owns: Vec::new(),
1508 use_worktrees: false,
1509 };
1510 let team_config = TeamConfig {
1511 name: "test".to_string(),
1512 agent: None,
1513 workflow_mode: WorkflowMode::Legacy,
1514 workflow_policy: WorkflowPolicy::default(),
1515 board: BoardConfig::default(),
1516 standup: StandupConfig {
1517 interval_secs: 900,
1518 output_lines: 30,
1519 },
1520 automation: AutomationConfig::default(),
1521 automation_sender: None,
1522 external_senders: Vec::new(),
1523 orchestrator_pane: false,
1524 orchestrator_position: OrchestratorPosition::Bottom,
1525 layout: None,
1526 cost: Default::default(),
1527 grafana: Default::default(),
1528 use_shim: false,
1529 use_sdk_mode: false,
1530 auto_respawn_on_crash: false,
1531 shim_health_check_interval_secs: 60,
1532 shim_health_timeout_secs: 120,
1533 shim_shutdown_timeout_secs: 30,
1534 shim_working_state_timeout_secs: 1800,
1535 pending_queue_max_age_secs: 600,
1536 event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1537 retro_min_duration_secs: 60,
1538 roles: vec![role],
1539 };
1540 let members = vec![member];
1541
1542 let interval = standup_interval_for_member_name(&team_config, &members, "manager");
1543 assert_eq!(interval, Some(Duration::from_secs(900)));
1544 }
1545
1546 #[test]
1547 fn standup_interval_for_engineer_returns_none() {
1548 let member = make_member("eng-1", RoleType::Engineer, Some("manager"));
1549 let role = RoleDef {
1550 name: "eng-1".to_string(),
1551 role_type: RoleType::Engineer,
1552 agent: Some("claude".to_string()),
1553 instances: 1,
1554 prompt: None,
1555 talks_to: vec![],
1556 channel: None,
1557 channel_config: None,
1558 nudge_interval_secs: None,
1559 receives_standup: None, standup_interval_secs: None,
1561 owns: Vec::new(),
1562 use_worktrees: false,
1563 };
1564 let team_config = TeamConfig {
1565 name: "test".to_string(),
1566 agent: None,
1567 workflow_mode: WorkflowMode::Legacy,
1568 workflow_policy: WorkflowPolicy::default(),
1569 board: BoardConfig::default(),
1570 standup: StandupConfig::default(),
1571 automation: AutomationConfig::default(),
1572 automation_sender: None,
1573 external_senders: Vec::new(),
1574 orchestrator_pane: false,
1575 orchestrator_position: OrchestratorPosition::Bottom,
1576 layout: None,
1577 cost: Default::default(),
1578 grafana: Default::default(),
1579 use_shim: false,
1580 use_sdk_mode: false,
1581 auto_respawn_on_crash: false,
1582 shim_health_check_interval_secs: 60,
1583 shim_health_timeout_secs: 120,
1584 shim_shutdown_timeout_secs: 30,
1585 shim_working_state_timeout_secs: 1800,
1586 pending_queue_max_age_secs: 600,
1587 event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1588 retro_min_duration_secs: 60,
1589 roles: vec![role],
1590 };
1591 let members = vec![member];
1592
1593 let interval = standup_interval_for_member_name(&team_config, &members, "eng-1");
1594 assert_eq!(interval, None);
1595 }
1596
1597 #[test]
1598 fn standup_interval_for_unknown_member_returns_none() {
1599 let team_config = TeamConfig {
1600 name: "test".to_string(),
1601 agent: None,
1602 workflow_mode: WorkflowMode::Legacy,
1603 workflow_policy: WorkflowPolicy::default(),
1604 board: BoardConfig::default(),
1605 standup: StandupConfig::default(),
1606 automation: AutomationConfig::default(),
1607 automation_sender: None,
1608 external_senders: Vec::new(),
1609 orchestrator_pane: false,
1610 orchestrator_position: OrchestratorPosition::Bottom,
1611 layout: None,
1612 cost: Default::default(),
1613 grafana: Default::default(),
1614 use_shim: false,
1615 use_sdk_mode: false,
1616 auto_respawn_on_crash: false,
1617 shim_health_check_interval_secs: 60,
1618 shim_health_timeout_secs: 120,
1619 shim_shutdown_timeout_secs: 30,
1620 shim_working_state_timeout_secs: 1800,
1621 pending_queue_max_age_secs: 600,
1622 event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1623 retro_min_duration_secs: 60,
1624 roles: vec![],
1625 };
1626
1627 let interval = standup_interval_for_member_name(&team_config, &[], "nobody");
1628 assert_eq!(interval, None);
1629 }
1630
1631 #[test]
1632 fn maybe_generate_standup_skips_when_standups_disabled() {
1633 let tmp = tempfile::tempdir().unwrap();
1634 let member = make_member("manager", RoleType::Manager, None);
1635 let role = RoleDef {
1636 name: "manager".to_string(),
1637 role_type: RoleType::Manager,
1638 agent: Some("claude".to_string()),
1639 instances: 1,
1640 prompt: None,
1641 talks_to: vec![],
1642 channel: None,
1643 channel_config: None,
1644 nudge_interval_secs: None,
1645 receives_standup: Some(true),
1646 standup_interval_secs: Some(60),
1647 owns: Vec::new(),
1648 use_worktrees: false,
1649 };
1650 let team_config = TeamConfig {
1651 name: "test".to_string(),
1652 agent: None,
1653 workflow_mode: WorkflowMode::Legacy,
1654 workflow_policy: WorkflowPolicy::default(),
1655 board: BoardConfig::default(),
1656 standup: StandupConfig {
1657 interval_secs: 60,
1658 output_lines: 30,
1659 },
1660 automation: AutomationConfig {
1661 standups: false,
1662 ..AutomationConfig::default()
1663 },
1664 automation_sender: None,
1665 external_senders: Vec::new(),
1666 orchestrator_pane: false,
1667 orchestrator_position: OrchestratorPosition::Bottom,
1668 layout: None,
1669 cost: Default::default(),
1670 grafana: Default::default(),
1671 use_shim: false,
1672 use_sdk_mode: false,
1673 auto_respawn_on_crash: false,
1674 shim_health_check_interval_secs: 60,
1675 shim_health_timeout_secs: 120,
1676 shim_shutdown_timeout_secs: 30,
1677 shim_working_state_timeout_secs: 1800,
1678 pending_queue_max_age_secs: 600,
1679 event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1680 retro_min_duration_secs: 60,
1681 roles: vec![role],
1682 };
1683 let members = vec![member];
1684 let mut last_standup = HashMap::new();
1685
1686 let generated = maybe_generate_standup(StandupGenerationContext {
1687 project_root: tmp.path(),
1688 team_config: &team_config,
1689 members: &members,
1690 watchers: &HashMap::new(),
1691 states: &HashMap::new(),
1692 pane_map: &HashMap::new(),
1693 telegram_bot: None,
1694 paused_standups: &HashSet::new(),
1695 last_standup: &mut last_standup,
1696 backend_health: &HashMap::new(),
1697 })
1698 .unwrap();
1699
1700 assert!(generated.is_empty());
1701 }
1702
1703 #[test]
1704 fn maybe_generate_standup_skips_paused_recipients() {
1705 let tmp = tempfile::tempdir().unwrap();
1706 let member = make_member("manager", RoleType::Manager, None);
1707 let eng = make_member("eng-1", RoleType::Engineer, Some("manager"));
1708 let role = RoleDef {
1709 name: "manager".to_string(),
1710 role_type: RoleType::Manager,
1711 agent: Some("claude".to_string()),
1712 instances: 1,
1713 prompt: None,
1714 talks_to: vec![],
1715 channel: None,
1716 channel_config: None,
1717 nudge_interval_secs: None,
1718 receives_standup: Some(true),
1719 standup_interval_secs: Some(1),
1720 owns: Vec::new(),
1721 use_worktrees: false,
1722 };
1723 let eng_role = RoleDef {
1724 name: "eng-1".to_string(),
1725 role_type: RoleType::Engineer,
1726 agent: Some("claude".to_string()),
1727 instances: 1,
1728 prompt: None,
1729 talks_to: vec![],
1730 channel: None,
1731 channel_config: None,
1732 nudge_interval_secs: None,
1733 receives_standup: Some(false),
1734 standup_interval_secs: None,
1735 owns: Vec::new(),
1736 use_worktrees: false,
1737 };
1738 let team_config = TeamConfig {
1739 name: "test".to_string(),
1740 agent: None,
1741 workflow_mode: WorkflowMode::Legacy,
1742 workflow_policy: WorkflowPolicy::default(),
1743 board: BoardConfig::default(),
1744 standup: StandupConfig {
1745 interval_secs: 1,
1746 output_lines: 30,
1747 },
1748 automation: AutomationConfig::default(),
1749 automation_sender: None,
1750 external_senders: Vec::new(),
1751 orchestrator_pane: false,
1752 orchestrator_position: OrchestratorPosition::Bottom,
1753 layout: None,
1754 cost: Default::default(),
1755 grafana: Default::default(),
1756 use_shim: false,
1757 use_sdk_mode: false,
1758 auto_respawn_on_crash: false,
1759 shim_health_check_interval_secs: 60,
1760 shim_health_timeout_secs: 120,
1761 shim_shutdown_timeout_secs: 30,
1762 shim_working_state_timeout_secs: 1800,
1763 pending_queue_max_age_secs: 600,
1764 event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1765 retro_min_duration_secs: 60,
1766 roles: vec![role, eng_role],
1767 };
1768 let members = vec![member, eng];
1769 let paused = HashSet::from(["manager".to_string()]);
1770 let mut last_standup = HashMap::from([(
1771 "manager".to_string(),
1772 Instant::now() - Duration::from_secs(100),
1773 )]);
1774
1775 let generated = maybe_generate_standup(StandupGenerationContext {
1776 project_root: tmp.path(),
1777 team_config: &team_config,
1778 members: &members,
1779 watchers: &HashMap::new(),
1780 states: &HashMap::new(),
1781 pane_map: &HashMap::new(),
1782 telegram_bot: None,
1783 paused_standups: &paused,
1784 last_standup: &mut last_standup,
1785 backend_health: &HashMap::new(),
1786 })
1787 .unwrap();
1788
1789 assert!(generated.is_empty());
1790 }
1791
1792 #[test]
1793 fn maybe_generate_standup_first_call_seeds_timer_without_generating() {
1794 let tmp = tempfile::tempdir().unwrap();
1795 let member = make_member("manager", RoleType::Manager, None);
1796 let eng = make_member("eng-1", RoleType::Engineer, Some("manager"));
1797 let role = RoleDef {
1798 name: "manager".to_string(),
1799 role_type: RoleType::Manager,
1800 agent: Some("claude".to_string()),
1801 instances: 1,
1802 prompt: None,
1803 talks_to: vec![],
1804 channel: None,
1805 channel_config: None,
1806 nudge_interval_secs: None,
1807 receives_standup: Some(true),
1808 standup_interval_secs: Some(1),
1809 owns: Vec::new(),
1810 use_worktrees: false,
1811 };
1812 let eng_role = RoleDef {
1813 name: "eng-1".to_string(),
1814 role_type: RoleType::Engineer,
1815 agent: Some("claude".to_string()),
1816 instances: 1,
1817 prompt: None,
1818 talks_to: vec![],
1819 channel: None,
1820 channel_config: None,
1821 nudge_interval_secs: None,
1822 receives_standup: Some(false),
1823 standup_interval_secs: None,
1824 owns: Vec::new(),
1825 use_worktrees: false,
1826 };
1827 let team_config = TeamConfig {
1828 name: "test".to_string(),
1829 agent: None,
1830 workflow_mode: WorkflowMode::Legacy,
1831 workflow_policy: WorkflowPolicy::default(),
1832 board: BoardConfig::default(),
1833 standup: StandupConfig {
1834 interval_secs: 1,
1835 output_lines: 30,
1836 },
1837 automation: AutomationConfig::default(),
1838 automation_sender: None,
1839 external_senders: Vec::new(),
1840 orchestrator_pane: false,
1841 orchestrator_position: OrchestratorPosition::Bottom,
1842 layout: None,
1843 cost: Default::default(),
1844 grafana: Default::default(),
1845 use_shim: false,
1846 use_sdk_mode: false,
1847 auto_respawn_on_crash: false,
1848 shim_health_check_interval_secs: 60,
1849 shim_health_timeout_secs: 120,
1850 shim_shutdown_timeout_secs: 30,
1851 shim_working_state_timeout_secs: 1800,
1852 pending_queue_max_age_secs: 600,
1853 event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1854 retro_min_duration_secs: 60,
1855 roles: vec![role, eng_role],
1856 };
1857 let members = vec![member, eng];
1858 let mut last_standup = HashMap::new(); let generated = maybe_generate_standup(StandupGenerationContext {
1861 project_root: tmp.path(),
1862 team_config: &team_config,
1863 members: &members,
1864 watchers: &HashMap::new(),
1865 states: &HashMap::new(),
1866 pane_map: &HashMap::new(),
1867 telegram_bot: None,
1868 paused_standups: &HashSet::new(),
1869 last_standup: &mut last_standup,
1870 backend_health: &HashMap::new(),
1871 })
1872 .unwrap();
1873
1874 assert!(generated.is_empty());
1876 assert!(last_standup.contains_key("manager"));
1877 }
1878}