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