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