Skip to main content

batty_cli/team/
events.rs

1//! Structured JSONL event stream for external consumers.
2
3use std::fs::{self, OpenOptions};
4use std::io::{BufWriter, Write};
5use std::path::{Path, PathBuf};
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use anyhow::{Context, Result};
9use serde::{Deserialize, Serialize};
10
11use super::DEFAULT_EVENT_LOG_MAX_BYTES;
12use super::merge::MergeMode;
13
14/// Bundled parameters for merge-confidence scoring events.
15pub struct MergeConfidenceInfo<'a> {
16    pub engineer: &'a str,
17    pub task: &'a str,
18    pub confidence: f64,
19    pub files_changed: usize,
20    pub lines_changed: usize,
21    pub has_migrations: bool,
22    pub has_config_changes: bool,
23    pub rename_count: usize,
24}
25
26pub struct AutoMergeDecisionInfo<'a> {
27    pub engineer: &'a str,
28    pub task: &'a str,
29    pub action_type: &'a str,
30    pub confidence: f64,
31    pub reason: &'a str,
32    pub details: &'a str,
33}
34
35pub struct VerificationPhaseChangeInfo<'a> {
36    pub engineer: &'a str,
37    pub task: &'a str,
38    pub from_phase: &'a str,
39    pub to_phase: &'a str,
40    pub iteration: u32,
41}
42
43pub struct QualityMetricsInfo<'a> {
44    pub backend: &'a str,
45    pub role: &'a str,
46    pub task: &'a str,
47    pub narration_ratio: f64,
48    pub commit_frequency: f64,
49    pub first_pass_test_rate: f64,
50    pub retry_rate: f64,
51    pub time_to_completion_secs: u64,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55
56pub struct TeamEvent {
57    pub event: String,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub action_type: Option<String>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub version: Option<String>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub git_ref: Option<String>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub role: Option<String>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub task: Option<String>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub recipient: Option<String>,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub from: Option<String>,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub to: Option<String>,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub restart: Option<bool>,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub restart_count: Option<u32>,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub load: Option<f64>,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub working_members: Option<u32>,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub total_members: Option<u32>,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub session_running: Option<bool>,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub reason: Option<String>,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub exit_category: Option<String>,
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub step: Option<String>,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub error: Option<String>,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub details: Option<String>,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub uptime_secs: Option<u64>,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub session_size_bytes: Option<u64>,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub output_bytes: Option<u64>,
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub filename: Option<String>,
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub content_hash: Option<String>,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub backend: Option<String>,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub success: Option<bool>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub merge_mode: Option<String>,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub narration_ratio: Option<f64>,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub commit_frequency: Option<f64>,
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub first_pass_test_rate: Option<f64>,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub retry_rate: Option<f64>,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub time_to_completion_secs: Option<u64>,
122    pub ts: u64,
123}
124
125impl TeamEvent {
126    fn now() -> u64 {
127        SystemTime::now()
128            .duration_since(UNIX_EPOCH)
129            .unwrap_or_default()
130            .as_secs()
131    }
132
133    fn base(event: &str) -> Self {
134        Self {
135            event: event.into(),
136            action_type: None,
137            version: None,
138            git_ref: None,
139            role: None,
140            task: None,
141            recipient: None,
142            from: None,
143            to: None,
144            restart: None,
145            restart_count: None,
146            load: None,
147            working_members: None,
148            total_members: None,
149            session_running: None,
150            reason: None,
151            exit_category: None,
152            step: None,
153            error: None,
154            details: None,
155            uptime_secs: None,
156            session_size_bytes: None,
157            output_bytes: None,
158            filename: None,
159            content_hash: None,
160            backend: None,
161            success: None,
162            merge_mode: None,
163            narration_ratio: None,
164            commit_frequency: None,
165            first_pass_test_rate: None,
166            retry_rate: None,
167            time_to_completion_secs: None,
168            ts: Self::now(),
169        }
170    }
171
172    pub fn daemon_started() -> Self {
173        Self {
174            action_type: Some("session_lifecycle".into()),
175            session_running: Some(true),
176            ..Self::base("daemon_started")
177        }
178    }
179
180    pub fn daemon_reloading() -> Self {
181        Self {
182            action_type: Some("session_lifecycle".into()),
183            session_running: Some(true),
184            ..Self::base("daemon_reloading")
185        }
186    }
187
188    pub fn daemon_reloaded() -> Self {
189        Self {
190            action_type: Some("session_lifecycle".into()),
191            session_running: Some(true),
192            ..Self::base("daemon_reloaded")
193        }
194    }
195
196    pub fn daemon_stopped() -> Self {
197        Self {
198            action_type: Some("session_lifecycle".into()),
199            session_running: Some(false),
200            ..Self::base("daemon_stopped")
201        }
202    }
203
204    pub fn daemon_stopped_with_reason(reason: &str, uptime_secs: u64) -> Self {
205        Self::daemon_stopped_with_reason_and_category(reason, uptime_secs, None)
206    }
207
208    pub fn daemon_stopped_with_reason_and_category(
209        reason: &str,
210        uptime_secs: u64,
211        exit_category: Option<&str>,
212    ) -> Self {
213        Self {
214            action_type: Some("session_lifecycle".into()),
215            session_running: Some(false),
216            reason: Some(reason.into()),
217            exit_category: exit_category.map(str::to_string),
218            uptime_secs: Some(uptime_secs),
219            ..Self::base("daemon_stopped")
220        }
221    }
222
223    pub fn daemon_exited(reason: &str, uptime_secs: u64, exit_category: &str) -> Self {
224        Self {
225            action_type: Some("session_lifecycle".into()),
226            session_running: Some(false),
227            reason: Some(reason.into()),
228            exit_category: Some(exit_category.into()),
229            uptime_secs: Some(uptime_secs),
230            ..Self::base("daemon_exited")
231        }
232    }
233
234    pub fn daemon_heartbeat(uptime_secs: u64) -> Self {
235        Self {
236            uptime_secs: Some(uptime_secs),
237            ..Self::base("daemon_heartbeat")
238        }
239    }
240
241    pub fn main_broken(commit: &str, suspects: &[String], summary: &str) -> Self {
242        Self {
243            reason: Some(commit.into()),
244            details: Some(format!("suspects: {}\n{}", suspects.join(", "), summary)),
245            success: Some(false),
246            ..Self::base("main_broken")
247        }
248    }
249
250    pub fn main_smoke_recovered(commit: &str, command: &str) -> Self {
251        Self {
252            reason: Some(commit.into()),
253            details: Some(format!("`{command}` passed")),
254            success: Some(true),
255            ..Self::base("main_smoke_recovered")
256        }
257    }
258
259    pub fn context_exhausted(
260        role: &str,
261        task: Option<u32>,
262        session_size_bytes: Option<u64>,
263    ) -> Self {
264        Self {
265            role: Some(role.into()),
266            task: task.map(|task_id| task_id.to_string()),
267            session_size_bytes,
268            ..Self::base("context_exhausted")
269        }
270    }
271
272    pub fn loop_step_error(step: &str, error: &str) -> Self {
273        Self {
274            step: Some(step.into()),
275            error: Some(error.into()),
276            ..Self::base("loop_step_error")
277        }
278    }
279
280    pub fn daemon_panic(reason: &str) -> Self {
281        Self {
282            reason: Some(reason.into()),
283            ..Self::base("daemon_panic")
284        }
285    }
286
287    pub fn task_assigned(role: &str, task: &str) -> Self {
288        Self {
289            role: Some(role.into()),
290            task: Some(task.into()),
291            ..Self::base("task_assigned")
292        }
293    }
294
295    pub fn dispatch_fallback_used(role: &str, task: &str, recipient: &str, reason: &str) -> Self {
296        Self {
297            role: Some(role.into()),
298            task: Some(task.into()),
299            recipient: Some(recipient.into()),
300            reason: Some(reason.into()),
301            ..Self::base("dispatch_fallback_used")
302        }
303    }
304
305    pub fn dispatch_overlap_prevented(task_id: u32, blocked_by: &[u32], details: &str) -> Self {
306        Self {
307            task: Some(task_id.to_string()),
308            reason: Some(
309                blocked_by
310                    .iter()
311                    .map(|task_id| task_id.to_string())
312                    .collect::<Vec<_>>()
313                    .join(","),
314            ),
315            details: Some(details.into()),
316            ..Self::base("dispatch_overlap_prevented")
317        }
318    }
319
320    pub fn dispatch_overlap_skipped(
321        candidate_task_id: u32,
322        conflicting_task_id: &str,
323        conflicting_files: &[String],
324    ) -> Self {
325        Self {
326            task: Some(candidate_task_id.to_string()),
327            reason: Some(conflicting_task_id.to_string()),
328            details: Some(conflicting_files.join(",")),
329            ..Self::base("dispatch_overlap_skipped")
330        }
331    }
332
333    pub fn cwd_corrected(role: &str, path: &str) -> Self {
334        Self {
335            role: Some(role.into()),
336            reason: Some(path.into()),
337            ..Self::base("cwd_corrected")
338        }
339    }
340
341    pub fn review_nudge_sent(role: &str, task: &str) -> Self {
342        Self {
343            role: Some(role.into()),
344            task: Some(task.into()),
345            ..Self::base("review_nudge_sent")
346        }
347    }
348
349    pub fn review_escalated(task: &str, reason: &str) -> Self {
350        Self {
351            task: Some(task.into()),
352            reason: Some(reason.into()),
353            ..Self::base("review_escalated")
354        }
355    }
356
357    pub fn state_reconciliation(role: Option<&str>, task: Option<&str>, correction: &str) -> Self {
358        Self {
359            role: role.map(str::to_string),
360            task: task.map(str::to_string),
361            reason: Some(correction.into()),
362            ..Self::base("state_reconciliation")
363        }
364    }
365
366    pub fn task_escalated(role: &str, task: &str, reason: Option<&str>) -> Self {
367        Self {
368            role: Some(role.into()),
369            task: Some(task.into()),
370            reason: reason.map(|r| r.into()),
371            ..Self::base("task_escalated")
372        }
373    }
374
375    pub fn task_stale(role: &str, task: &str, reason: &str) -> Self {
376        Self {
377            role: Some(role.into()),
378            task: Some(task.into()),
379            reason: Some(reason.into()),
380            ..Self::base("task_stale")
381        }
382    }
383
384    pub fn task_aged(task: &str, reason: &str) -> Self {
385        Self {
386            task: Some(task.into()),
387            reason: Some(reason.into()),
388            ..Self::base("task_aged")
389        }
390    }
391
392    pub fn review_stale(task: &str, reason: &str) -> Self {
393        Self {
394            task: Some(task.into()),
395            reason: Some(reason.into()),
396            ..Self::base("review_stale")
397        }
398    }
399
400    pub fn task_unblocked(role: &str, task: &str) -> Self {
401        Self {
402            role: Some(role.into()),
403            task: Some(task.into()),
404            ..Self::base("task_unblocked")
405        }
406    }
407
408    pub fn task_claim_created(role: &str, task: &str, ttl_secs: u64, expires_at: &str) -> Self {
409        Self {
410            role: Some(role.into()),
411            task: Some(task.into()),
412            reason: Some(format!("ttl_secs={ttl_secs} expires_at={expires_at}")),
413            ..Self::base("task_claim_created")
414        }
415    }
416
417    pub fn task_claim_progress(role: &str, task: &str, progress_type: &str) -> Self {
418        Self {
419            role: Some(role.into()),
420            task: Some(task.into()),
421            reason: Some(progress_type.into()),
422            ..Self::base("task_claim_progress")
423        }
424    }
425
426    pub fn task_claim_warning(role: &str, task: &str, expires_in_secs: u64) -> Self {
427        Self {
428            role: Some(role.into()),
429            task: Some(task.into()),
430            reason: Some(format!("expires_in_secs={expires_in_secs}")),
431            ..Self::base("task_claim_warning")
432        }
433    }
434
435    pub fn task_claim_expired(
436        role: &str,
437        task: &str,
438        reclaimed: bool,
439        time_held_secs: Option<u64>,
440    ) -> Self {
441        let mut reason = format!("reclaimed={reclaimed}");
442        if let Some(time_held_secs) = time_held_secs {
443            reason.push_str(&format!(" time_held_secs={time_held_secs}"));
444        }
445        Self {
446            role: Some(role.into()),
447            task: Some(task.into()),
448            reason: Some(reason),
449            ..Self::base("task_claim_expired")
450        }
451    }
452
453    pub fn task_claim_extended(role: &str, task: &str, new_expires_at: &str) -> Self {
454        Self {
455            role: Some(role.into()),
456            task: Some(task.into()),
457            reason: Some(format!("new_expires_at={new_expires_at}")),
458            ..Self::base("task_claim_extended")
459        }
460    }
461
462    pub fn scope_fence_violation(role: &str, task_id: u32, details: &str) -> Self {
463        Self {
464            role: Some(role.into()),
465            task: Some(task_id.to_string()),
466            reason: Some(details.into()),
467            ..Self::base("scope_fence_violation")
468        }
469    }
470
471    pub fn board_task_archived(task: &str, role: Option<&str>) -> Self {
472        Self {
473            role: role.map(str::to_string),
474            task: Some(task.into()),
475            ..Self::base("board_task_archived")
476        }
477    }
478
479    pub fn auto_doctor_action(
480        action_type: &str,
481        task_id: Option<u32>,
482        engineer: Option<&str>,
483        details: &str,
484    ) -> Self {
485        Self {
486            action_type: Some(action_type.into()),
487            task: task_id.map(|task_id| task_id.to_string()),
488            role: engineer.map(str::to_string),
489            details: Some(details.into()),
490            ..Self::base("auto_doctor_action")
491        }
492    }
493
494    pub fn performance_regression(task: &str, reason: &str) -> Self {
495        Self {
496            task: Some(task.into()),
497            reason: Some(reason.into()),
498            ..Self::base("performance_regression")
499        }
500    }
501
502    pub fn task_completed(role: &str, task: Option<&str>) -> Self {
503        Self {
504            role: Some(role.into()),
505            task: task.map(|t| t.into()),
506            ..Self::base("task_completed")
507        }
508    }
509
510    pub fn quality_metrics_recorded(info: &QualityMetricsInfo<'_>) -> Self {
511        Self {
512            role: Some(info.role.into()),
513            task: Some(info.task.into()),
514            backend: Some(info.backend.into()),
515            narration_ratio: Some(info.narration_ratio),
516            commit_frequency: Some(info.commit_frequency),
517            first_pass_test_rate: Some(info.first_pass_test_rate),
518            retry_rate: Some(info.retry_rate),
519            time_to_completion_secs: Some(info.time_to_completion_secs),
520            ..Self::base("quality_metrics_recorded")
521        }
522    }
523
524    pub fn standup_generated(recipient: &str) -> Self {
525        Self {
526            recipient: Some(recipient.into()),
527            ..Self::base("standup_generated")
528        }
529    }
530
531    pub fn supervisory_digest_emitted(
532        role: &str,
533        notice_count: u32,
534        suppressed_duplicates: u32,
535    ) -> Self {
536        Self {
537            role: Some(role.into()),
538            details: Some(format!(
539                "notice_count={notice_count} suppressed_duplicates={suppressed_duplicates}"
540            )),
541            ..Self::base("supervisory_digest_emitted")
542        }
543    }
544
545    pub fn retro_generated() -> Self {
546        Self::base("retro_generated")
547    }
548
549    pub fn pattern_detected(pattern_type: &str, frequency: u32) -> Self {
550        Self {
551            reason: Some(format!("{pattern_type}:{frequency}")),
552            ..Self::base("pattern_detected")
553        }
554    }
555
556    pub fn disk_hygiene_cleanup(summary: &str) -> Self {
557        Self {
558            details: Some(summary.into()),
559            ..Self::base("disk_hygiene_cleanup")
560        }
561    }
562
563    pub fn member_crashed(role: &str, restart: bool) -> Self {
564        Self {
565            role: Some(role.into()),
566            restart: Some(restart),
567            ..Self::base("member_crashed")
568        }
569    }
570
571    pub fn pane_death(role: &str) -> Self {
572        Self {
573            role: Some(role.into()),
574            ..Self::base("pane_death")
575        }
576    }
577
578    pub fn pane_respawned(role: &str) -> Self {
579        Self {
580            role: Some(role.into()),
581            ..Self::base("pane_respawned")
582        }
583    }
584
585    pub fn shim_disconnect(role: &str, reason: &str, details: &str, expected: bool) -> Self {
586        Self {
587            role: Some(role.into()),
588            reason: Some(reason.into()),
589            details: Some(details.into()),
590            success: Some(expected),
591            ..Self::base("shim_disconnect")
592        }
593    }
594
595    pub fn narration_detected(role: &str, task: Option<u32>) -> Self {
596        Self {
597            role: Some(role.into()),
598            task: task.map(|id| id.to_string()),
599            ..Self::base("narration_detected")
600        }
601    }
602
603    pub fn narration_nudged(role: &str, task: Option<u32>) -> Self {
604        Self {
605            role: Some(role.into()),
606            task: task.map(|id| id.to_string()),
607            ..Self::base("narration_nudged")
608        }
609    }
610
611    pub fn narration_restart(role: &str, task: Option<u32>) -> Self {
612        Self {
613            role: Some(role.into()),
614            task: task.map(|id| id.to_string()),
615            ..Self::base("narration_restart")
616        }
617    }
618
619    pub fn narration_rejection(role: &str, task_id: u32, rejection_count: u32) -> Self {
620        Self {
621            role: Some(role.into()),
622            task: Some(task_id.to_string()),
623            reason: Some(format!("rejection_count={rejection_count}")),
624            ..Self::base("narration_rejection")
625        }
626    }
627
628    pub fn tact_cycle_triggered(role: &str, idle_engineers: u32, board_summary: &str) -> Self {
629        Self {
630            role: Some(role.into()),
631            working_members: Some(idle_engineers),
632            reason: Some(board_summary.into()),
633            ..Self::base("tact_cycle_triggered")
634        }
635    }
636
637    pub fn tact_tasks_created(
638        role: &str,
639        tasks_created: u32,
640        latency_secs: u64,
641        success: bool,
642        error: Option<&str>,
643    ) -> Self {
644        Self {
645            role: Some(role.into()),
646            restart_count: Some(tasks_created),
647            uptime_secs: Some(latency_secs),
648            reason: Some(if success { "success" } else { "failure" }.into()),
649            error: error.map(str::to_string),
650            ..Self::base("tact_tasks_created")
651        }
652    }
653
654    pub fn context_pressure_warning(
655        role: &str,
656        task: Option<u32>,
657        pressure_score: u64,
658        threshold: u64,
659        output_bytes: u64,
660    ) -> Self {
661        Self {
662            role: Some(role.into()),
663            task: task.map(|id| id.to_string()),
664            load: Some(pressure_score as f64),
665            output_bytes: Some(output_bytes),
666            reason: Some(format!("threshold={threshold}")),
667            ..Self::base("context_pressure_warning")
668        }
669    }
670
671    pub fn stall_detected(role: &str, task: Option<u32>, stall_duration_secs: u64) -> Self {
672        Self::stall_detected_with_reason(role, task, stall_duration_secs, None)
673    }
674
675    pub fn stall_detected_with_reason(
676        role: &str,
677        task: Option<u32>,
678        stall_duration_secs: u64,
679        reason: Option<&str>,
680    ) -> Self {
681        Self {
682            role: Some(role.into()),
683            task: task.map(|id| id.to_string()),
684            uptime_secs: Some(stall_duration_secs),
685            reason: reason.map(str::to_string),
686            ..Self::base("stall_detected")
687        }
688    }
689
690    /// Record a backend health state change for an agent.
691    ///
692    /// `reason` encodes the transition, e.g. "healthy→unreachable".
693    pub fn health_changed(role: &str, reason: &str) -> Self {
694        Self {
695            role: Some(role.into()),
696            reason: Some(reason.into()),
697            ..Self::base("health_changed")
698        }
699    }
700
701    pub fn message_routed(from: &str, to: &str) -> Self {
702        Self {
703            from: Some(from.into()),
704            to: Some(to.into()),
705            ..Self::base("message_routed")
706        }
707    }
708
709    pub fn discord_event_sent(channel: &str, source_event: &str) -> Self {
710        Self {
711            action_type: Some("notification".into()),
712            recipient: Some(channel.into()),
713            reason: Some(source_event.into()),
714            ..Self::base("discord_event_sent")
715        }
716    }
717
718    pub fn notification_delivery_sample(
719        from: &str,
720        to: &str,
721        latency_secs: u64,
722        isolation_mode: &str,
723    ) -> Self {
724        Self {
725            action_type: Some(isolation_mode.into()),
726            from: Some(from.into()),
727            to: Some(to.into()),
728            uptime_secs: Some(latency_secs),
729            ..Self::base("notification_delivery_sample")
730        }
731    }
732
733    pub fn inbox_message_expired(target: &str, from: &str, message_age_secs: u64) -> Self {
734        Self {
735            recipient: Some(target.into()),
736            from: Some(from.into()),
737            uptime_secs: Some(message_age_secs),
738            ..Self::base("inbox_message_expired")
739        }
740    }
741
742    pub fn inbox_message_deduplicated(target: &str, from: &str, signature: u64) -> Self {
743        Self {
744            recipient: Some(target.into()),
745            from: Some(from.into()),
746            content_hash: Some(format!("{signature:016x}")),
747            ..Self::base("inbox_message_deduplicated")
748        }
749    }
750
751    pub fn inbox_batch_delivered(
752        target: &str,
753        message_count: usize,
754        escalation_count: usize,
755    ) -> Self {
756        Self {
757            recipient: Some(target.into()),
758            restart_count: Some(message_count as u32),
759            reason: Some(format!("escalation_count={escalation_count}")),
760            ..Self::base("inbox_batch_delivered")
761        }
762    }
763
764    pub fn backend_quota_exhausted(role: &str, reason: &str) -> Self {
765        Self {
766            role: Some(role.into()),
767            reason: Some(reason.into()),
768            ..Self::base("backend_quota_exhausted")
769        }
770    }
771
772    pub fn agent_spawned(role: &str) -> Self {
773        Self {
774            role: Some(role.into()),
775            ..Self::base("agent_spawned")
776        }
777    }
778
779    pub fn agent_restarted(role: &str, task: &str, reason: &str, restart_count: u32) -> Self {
780        Self {
781            role: Some(role.into()),
782            task: Some(task.into()),
783            reason: Some(reason.into()),
784            restart_count: Some(restart_count),
785            ..Self::base("agent_restarted")
786        }
787    }
788
789    pub fn agent_handoff(role: &str, task: &str, reason: &str, success: bool) -> Self {
790        Self {
791            role: Some(role.into()),
792            task: Some(task.into()),
793            reason: Some(reason.into()),
794            success: Some(success),
795            ..Self::base("agent_handoff")
796        }
797    }
798
799    pub fn handoff_injected(role: &str, task: &str, reason: &str) -> Self {
800        Self {
801            role: Some(role.into()),
802            task: Some(task.into()),
803            reason: Some(reason.into()),
804            success: Some(true),
805            ..Self::base("handoff_injected")
806        }
807    }
808
809    pub fn task_resumed(role: &str, task: &str, reason: &str, restart_count: u32) -> Self {
810        Self {
811            role: Some(role.into()),
812            task: Some(task.into()),
813            reason: Some(reason.into()),
814            restart_count: Some(restart_count),
815            ..Self::base("task_resumed")
816        }
817    }
818
819    pub fn delivery_failed(role: &str, from: &str, reason: &str) -> Self {
820        Self::delivery_failed_with_details(role, from, reason, None)
821    }
822
823    pub fn delivery_failed_with_details(
824        role: &str,
825        from: &str,
826        reason: &str,
827        details: Option<&str>,
828    ) -> Self {
829        Self {
830            role: Some(role.into()),
831            from: Some(from.into()),
832            reason: Some(reason.into()),
833            details: details.map(str::to_string),
834            ..Self::base("delivery_failed")
835        }
836    }
837
838    pub fn task_auto_merged(
839        engineer: &str,
840        task: &str,
841        confidence: f64,
842        files_changed: usize,
843        lines_changed: usize,
844    ) -> Self {
845        Self::task_auto_merged_with_mode(
846            engineer,
847            task,
848            confidence,
849            files_changed,
850            lines_changed,
851            None,
852        )
853    }
854
855    pub fn task_auto_merged_with_mode(
856        engineer: &str,
857        task: &str,
858        confidence: f64,
859        files_changed: usize,
860        lines_changed: usize,
861        merge_mode: Option<MergeMode>,
862    ) -> Self {
863        Self {
864            role: Some(engineer.into()),
865            task: Some(task.into()),
866            load: Some(confidence),
867            reason: Some(format!("files={} lines={}", files_changed, lines_changed)),
868            success: Some(true),
869            merge_mode: merge_mode.map(|mode| mode.as_str().to_string()),
870            ..Self::base("task_auto_merged")
871        }
872    }
873
874    pub fn auto_merge_decision_recorded(info: &AutoMergeDecisionInfo<'_>) -> Self {
875        Self {
876            role: Some(info.engineer.into()),
877            task: Some(info.task.into()),
878            action_type: Some(info.action_type.into()),
879            load: Some(info.confidence),
880            reason: Some(info.reason.into()),
881            details: Some(info.details.into()),
882            ..Self::base("auto_merge_decision_recorded")
883        }
884    }
885
886    pub fn auto_merge_post_verify_result(
887        engineer: &str,
888        task: &str,
889        success: Option<bool>,
890        reason: &str,
891        details: Option<&str>,
892    ) -> Self {
893        Self {
894            role: Some(engineer.into()),
895            task: Some(task.into()),
896            success,
897            reason: Some(reason.into()),
898            details: details.map(str::to_string),
899            ..Self::base("auto_merge_post_verify_result")
900        }
901    }
902
903    pub fn task_manual_merged(task: &str) -> Self {
904        Self::task_manual_merged_with_mode(task, None)
905    }
906
907    pub fn task_manual_merged_with_mode(task: &str, merge_mode: Option<MergeMode>) -> Self {
908        Self {
909            task: Some(task.into()),
910            success: Some(true),
911            merge_mode: merge_mode.map(|mode| mode.as_str().to_string()),
912            ..Self::base("task_manual_merged")
913        }
914    }
915
916    pub fn task_merge_failed(
917        engineer: &str,
918        task: &str,
919        merge_mode: Option<MergeMode>,
920        details: &str,
921    ) -> Self {
922        Self {
923            role: Some(engineer.into()),
924            task: Some(task.into()),
925            success: Some(false),
926            merge_mode: merge_mode.map(|mode| mode.as_str().to_string()),
927            reason: Some("merge_failed".into()),
928            details: Some(details.into()),
929            ..Self::base("task_merge_failed")
930        }
931    }
932
933    /// Emitted for every completed task to record its merge confidence score.
934    pub fn merge_confidence_scored(info: &MergeConfidenceInfo<'_>) -> Self {
935        let detail = format!(
936            "files={} lines={} migrations={} config={} renames={}",
937            info.files_changed,
938            info.lines_changed,
939            info.has_migrations,
940            info.has_config_changes,
941            info.rename_count
942        );
943        Self {
944            role: Some(info.engineer.into()),
945            task: Some(info.task.into()),
946            load: Some(info.confidence),
947            reason: Some(detail),
948            ..Self::base("merge_confidence_scored")
949        }
950    }
951
952    pub fn verification_phase_changed(info: &VerificationPhaseChangeInfo<'_>) -> Self {
953        Self {
954            role: Some(info.engineer.into()),
955            task: Some(info.task.into()),
956            step: Some(info.to_phase.into()),
957            reason: Some(format!(
958                "from={} to={} iteration={}",
959                info.from_phase, info.to_phase, info.iteration
960            )),
961            restart_count: Some(info.iteration),
962            ..Self::base("verification_phase_changed")
963        }
964    }
965
966    pub fn verification_evidence_collected(
967        engineer: &str,
968        task: &str,
969        evidence_kind: &str,
970        detail: &str,
971    ) -> Self {
972        Self {
973            role: Some(engineer.into()),
974            task: Some(task.into()),
975            step: Some(evidence_kind.into()),
976            reason: Some(detail.into()),
977            ..Self::base("verification_evidence_collected")
978        }
979    }
980
981    pub fn verification_max_iterations_reached(
982        engineer: &str,
983        task: &str,
984        iteration: u32,
985        escalated_to: &str,
986    ) -> Self {
987        Self {
988            role: Some(engineer.into()),
989            task: Some(task.into()),
990            recipient: Some(escalated_to.into()),
991            reason: Some(format!("iteration={iteration}")),
992            restart_count: Some(iteration),
993            ..Self::base("verification_max_iterations_reached")
994        }
995    }
996
997    pub fn review_escalated_by_role(role: &str, task: &str) -> Self {
998        Self {
999            role: Some(role.into()),
1000            task: Some(task.into()),
1001            ..Self::base("review_escalated")
1002        }
1003    }
1004
1005    pub fn pipeline_starvation_detected(idle_engineers: usize, todo_tasks: usize) -> Self {
1006        Self {
1007            reason: Some(format!(
1008                "idle_engineers={idle_engineers} todo_tasks={todo_tasks}"
1009            )),
1010            ..Self::base("pipeline_starvation_detected")
1011        }
1012    }
1013
1014    pub fn task_reworked(role: &str, task: &str) -> Self {
1015        Self {
1016            role: Some(role.into()),
1017            task: Some(task.into()),
1018            ..Self::base("task_reworked")
1019        }
1020    }
1021
1022    pub fn task_recycled(task_id: u32, cron_expr: &str) -> Self {
1023        Self {
1024            task: Some(format!("#{task_id}")),
1025            reason: Some(cron_expr.into()),
1026            ..Self::base("task_recycled")
1027        }
1028    }
1029
1030    pub fn barrier_artifact_created(role: &str, filename: &str, content_hash: &str) -> Self {
1031        Self {
1032            role: Some(role.into()),
1033            filename: Some(filename.into()),
1034            content_hash: Some(content_hash.into()),
1035            ..Self::base("barrier_artifact_created")
1036        }
1037    }
1038
1039    pub fn barrier_artifact_read(role: &str, filename: &str, content_hash: &str) -> Self {
1040        Self {
1041            role: Some(role.into()),
1042            filename: Some(filename.into()),
1043            content_hash: Some(content_hash.into()),
1044            ..Self::base("barrier_artifact_read")
1045        }
1046    }
1047
1048    pub fn barrier_violation_attempt(role: &str, filename: &str, reason: &str) -> Self {
1049        Self {
1050            role: Some(role.into()),
1051            filename: Some(filename.into()),
1052            reason: Some(reason.into()),
1053            ..Self::base("barrier_violation_attempt")
1054        }
1055    }
1056
1057    pub fn load_snapshot(working_members: u32, total_members: u32, session_running: bool) -> Self {
1058        let load = if total_members == 0 {
1059            0.0
1060        } else {
1061            working_members as f64 / total_members as f64
1062        };
1063        Self {
1064            load: Some(load),
1065            working_members: Some(working_members),
1066            total_members: Some(total_members),
1067            session_running: Some(session_running),
1068            ..Self::base("load_snapshot")
1069        }
1070    }
1071
1072    pub fn parity_updated(summary: &crate::team::parity::ParitySummary) -> Self {
1073        Self {
1074            load: Some(summary.overall_parity_pct as f64 / 100.0),
1075            reason: Some(format!(
1076                "total={} spec={} tests={} implementation={} verified_pass={} verified_fail={}",
1077                summary.total_behaviors,
1078                summary.spec_complete,
1079                summary.tests_complete,
1080                summary.implementation_complete,
1081                summary.verified_pass,
1082                summary.verified_fail
1083            )),
1084            ..Self::base("parity_updated")
1085        }
1086    }
1087
1088    pub fn release_succeeded(
1089        version: &str,
1090        git_ref: &str,
1091        tag: &str,
1092        notes_path: Option<&str>,
1093    ) -> Self {
1094        Self {
1095            action_type: Some("release".into()),
1096            version: Some(version.into()),
1097            git_ref: Some(git_ref.into()),
1098            task: Some(tag.into()),
1099            reason: Some("tag_created".into()),
1100            success: Some(true),
1101            details: notes_path.map(str::to_string),
1102            ..Self::base("release_succeeded")
1103        }
1104    }
1105
1106    pub fn release_failed(
1107        version: Option<&str>,
1108        git_ref: Option<&str>,
1109        tag: Option<&str>,
1110        reason: &str,
1111        details: Option<&str>,
1112    ) -> Self {
1113        Self {
1114            action_type: Some("release".into()),
1115            version: version.map(str::to_string),
1116            git_ref: git_ref.map(str::to_string),
1117            task: tag.map(str::to_string),
1118            reason: Some(reason.into()),
1119            details: details.map(str::to_string),
1120            success: Some(false),
1121            ..Self::base("release_failed")
1122        }
1123    }
1124
1125    pub fn worktree_reconciled(role: &str, branch: &str) -> Self {
1126        Self {
1127            role: Some(role.into()),
1128            reason: Some(format!("branch '{branch}' merged into main")),
1129            ..Self::base("worktree_reconciled")
1130        }
1131    }
1132
1133    pub fn worktree_refreshed(role: &str, reason: &str) -> Self {
1134        Self {
1135            role: Some(role.into()),
1136            reason: Some(reason.into()),
1137            ..Self::base("worktree_refreshed")
1138        }
1139    }
1140
1141    /// Emitted when the daemon reconciles a topology change.
1142    ///
1143    /// `reason` contains a human-readable summary (e.g. "+2 added, -1 removed").
1144    pub fn topology_changed(added: u32, removed: u32, reason: &str) -> Self {
1145        Self {
1146            working_members: Some(added),
1147            total_members: Some(removed),
1148            reason: Some(reason.into()),
1149            ..Self::base("topology_changed")
1150        }
1151    }
1152
1153    /// Emitted when an agent is removed during a scale-down.
1154    pub fn agent_removed(role: &str, reason: &str) -> Self {
1155        Self {
1156            role: Some(role.into()),
1157            reason: Some(reason.into()),
1158            ..Self::base("agent_removed")
1159        }
1160    }
1161}
1162
1163// ---------------------------------------------------------------------------
1164// Structured lifecycle events (typed, schema-versioned)
1165// ---------------------------------------------------------------------------
1166
1167/// Agent session lifecycle phases.
1168#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1169#[serde(rename_all = "snake_case")]
1170pub enum SessionLifecycle {
1171    Spawning,
1172    Ready,
1173    Working,
1174    Blocked,
1175    Idle,
1176    Finished,
1177    Failed,
1178}
1179
1180/// Task lifecycle phases.
1181#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1182#[serde(rename_all = "snake_case")]
1183pub enum TaskLifecycle {
1184    Claimed,
1185    InProgress,
1186    TestsRunning,
1187    TestsPassed,
1188    TestsFailed,
1189    Review,
1190    Merged,
1191    Rejected,
1192}
1193
1194/// Merge lifecycle phases.
1195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1196#[serde(rename_all = "snake_case")]
1197pub enum MergeLifecycle {
1198    Started,
1199    Conflict,
1200    Success,
1201}
1202
1203/// Wrapper enum over all lifecycle event types.
1204#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1205#[serde(tag = "category", content = "phase")]
1206pub enum LifecycleEventType {
1207    #[serde(rename = "session")]
1208    Session(SessionLifecycle),
1209    #[serde(rename = "task")]
1210    Task(TaskLifecycle),
1211    #[serde(rename = "merge")]
1212    Merge(MergeLifecycle),
1213}
1214
1215/// A typed, schema-versioned lifecycle event written to events.jsonl.
1216#[derive(Debug, Clone, Serialize, Deserialize)]
1217pub struct LifecycleEvent {
1218    pub schema_version: u32,
1219    pub ts: u64,
1220    pub event_type: LifecycleEventType,
1221    pub agent_name: String,
1222    #[serde(skip_serializing_if = "Option::is_none")]
1223    pub task_id: Option<u32>,
1224    #[serde(skip_serializing_if = "Option::is_none")]
1225    pub details: Option<String>,
1226}
1227
1228impl LifecycleEvent {
1229    /// Current schema version for lifecycle events.
1230    pub const SCHEMA_VERSION: u32 = 1;
1231
1232    /// Create a new lifecycle event with the current timestamp.
1233    pub fn new(
1234        event_type: LifecycleEventType,
1235        agent_name: &str,
1236        task_id: Option<u32>,
1237        details: Option<String>,
1238    ) -> Self {
1239        Self {
1240            schema_version: Self::SCHEMA_VERSION,
1241            ts: SystemTime::now()
1242                .duration_since(UNIX_EPOCH)
1243                .unwrap_or_default()
1244                .as_secs(),
1245            event_type,
1246            agent_name: agent_name.to_string(),
1247            task_id,
1248            details,
1249        }
1250    }
1251}
1252
1253/// Write a lifecycle event as a JSON line to the given events.jsonl path.
1254pub fn emit_lifecycle_event(path: &Path, event: &LifecycleEvent) -> Result<()> {
1255    if let Some(parent) = path.parent() {
1256        fs::create_dir_all(parent)?;
1257    }
1258    let mut file = OpenOptions::new()
1259        .create(true)
1260        .append(true)
1261        .open(path)
1262        .with_context(|| format!("failed to open lifecycle event log: {}", path.display()))?;
1263    let json = serde_json::to_string(event)?;
1264    writeln!(file, "{json}")?;
1265    file.flush()?;
1266    Ok(())
1267}
1268
1269pub struct EventSink {
1270    writer: Box<dyn Write + Send>,
1271    path: PathBuf,
1272    max_bytes: Option<u64>,
1273}
1274
1275impl EventSink {
1276    pub fn new(path: &Path) -> Result<Self> {
1277        Self::new_with_max_bytes(path, DEFAULT_EVENT_LOG_MAX_BYTES)
1278    }
1279
1280    pub fn new_with_max_bytes(path: &Path, max_bytes: u64) -> Result<Self> {
1281        if let Some(parent) = path.parent() {
1282            std::fs::create_dir_all(parent)?;
1283        }
1284        rotate_event_log_if_needed(path, max_bytes, 0)?;
1285        let file = OpenOptions::new()
1286            .create(true)
1287            .append(true)
1288            .open(path)
1289            .with_context(|| format!("failed to open event sink: {}", path.display()))?;
1290        Ok(Self {
1291            writer: Box::new(BufWriter::new(file)),
1292            path: path.to_path_buf(),
1293            max_bytes: Some(max_bytes),
1294        })
1295    }
1296
1297    #[cfg(test)]
1298    pub(crate) fn from_writer(path: &Path, writer: impl Write + Send + 'static) -> Self {
1299        Self {
1300            writer: Box::new(writer),
1301            path: path.to_path_buf(),
1302            max_bytes: None,
1303        }
1304    }
1305
1306    pub fn emit(&mut self, event: TeamEvent) -> Result<()> {
1307        let json = serde_json::to_string(&event)?;
1308        self.rotate_if_needed((json.len() + 1) as u64)?;
1309        writeln!(self.writer, "{json}")?;
1310        self.writer.flush()?;
1311        Ok(())
1312    }
1313
1314    pub fn path(&self) -> &Path {
1315        &self.path
1316    }
1317
1318    fn rotate_if_needed(&mut self, next_entry_bytes: u64) -> Result<()> {
1319        let Some(max_bytes) = self.max_bytes else {
1320            return Ok(());
1321        };
1322        self.writer.flush()?;
1323        if rotate_event_log_if_needed(&self.path, max_bytes, next_entry_bytes)? {
1324            self.writer = Box::new(BufWriter::new(
1325                OpenOptions::new()
1326                    .create(true)
1327                    .append(true)
1328                    .open(&self.path)
1329                    .with_context(|| {
1330                        format!("failed to reopen event sink: {}", self.path.display())
1331                    })?,
1332            ));
1333        }
1334        Ok(())
1335    }
1336}
1337
1338fn rotated_event_log_path(path: &Path) -> PathBuf {
1339    PathBuf::from(format!("{}.1", path.display()))
1340}
1341
1342fn rotate_event_log_if_needed(path: &Path, max_bytes: u64, next_entry_bytes: u64) -> Result<bool> {
1343    let len = match fs::metadata(path) {
1344        Ok(metadata) => metadata.len(),
1345        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
1346        Err(error) => {
1347            return Err(error).with_context(|| format!("failed to stat {}", path.display()));
1348        }
1349    };
1350
1351    if len == 0 {
1352        return Ok(false);
1353    }
1354
1355    if len.saturating_add(next_entry_bytes) <= max_bytes {
1356        return Ok(false);
1357    }
1358
1359    let rotated = rotated_event_log_path(path);
1360    if rotated.exists() {
1361        fs::remove_file(&rotated)
1362            .with_context(|| format!("failed to remove {}", rotated.display()))?;
1363    }
1364    fs::rename(path, &rotated).with_context(|| {
1365        format!(
1366            "failed to rotate event log {} to {}",
1367            path.display(),
1368            rotated.display()
1369        )
1370    })?;
1371    Ok(true)
1372}
1373
1374pub fn read_events(path: &Path) -> Result<Vec<TeamEvent>> {
1375    if !path.exists() {
1376        return Ok(Vec::new());
1377    }
1378    let content = fs::read_to_string(path).context("failed to read event log")?;
1379    let mut events = Vec::new();
1380    for line in content.lines() {
1381        let line = line.trim();
1382        if line.is_empty() {
1383            continue;
1384        }
1385        if let Ok(event) = serde_json::from_str::<TeamEvent>(line) {
1386            events.push(event);
1387        }
1388    }
1389    Ok(events)
1390}
1391
1392#[cfg(test)]
1393mod tests {
1394    use std::sync::{Arc, Mutex};
1395    use std::thread;
1396
1397    use super::*;
1398
1399    #[test]
1400    fn event_serializes_to_json() {
1401        let event = TeamEvent::task_assigned("eng-1-1", "fix auth bug");
1402        let json = serde_json::to_string(&event).unwrap();
1403        assert!(json.contains("\"event\":\"task_assigned\""));
1404        assert!(json.contains("\"role\":\"eng-1-1\""));
1405        assert!(json.contains("\"task\":\"fix auth bug\""));
1406        assert!(json.contains("\"ts\":"));
1407    }
1408
1409    #[test]
1410    fn optional_fields_omitted() {
1411        let event = TeamEvent::daemon_started();
1412        let json = serde_json::to_string(&event).unwrap();
1413        assert!(!json.contains("\"role\""));
1414        assert!(!json.contains("\"task\""));
1415    }
1416
1417    #[test]
1418    fn event_sink_writes_jsonl() {
1419        let tmp = tempfile::tempdir().unwrap();
1420        let path = tmp.path().join("events.jsonl");
1421        let mut sink = EventSink::new(&path).unwrap();
1422        sink.emit(TeamEvent::daemon_started()).unwrap();
1423        sink.emit(TeamEvent::task_assigned("eng-1", "fix bug"))
1424            .unwrap();
1425        sink.emit(TeamEvent::daemon_stopped()).unwrap();
1426
1427        let content = std::fs::read_to_string(&path).unwrap();
1428        let lines: Vec<&str> = content.lines().collect();
1429        assert_eq!(lines.len(), 3);
1430        assert!(lines[0].contains("daemon_started"));
1431        assert!(lines[1].contains("task_assigned"));
1432        assert!(lines[2].contains("daemon_stopped"));
1433    }
1434
1435    #[test]
1436    fn all_event_variants_serialize_with_correct_event_field() {
1437        let variants: Vec<(&str, TeamEvent)> = vec![
1438            ("daemon_started", TeamEvent::daemon_started()),
1439            ("daemon_reloading", TeamEvent::daemon_reloading()),
1440            ("daemon_reloaded", TeamEvent::daemon_reloaded()),
1441            ("daemon_stopped", TeamEvent::daemon_stopped()),
1442            (
1443                "daemon_stopped",
1444                TeamEvent::daemon_stopped_with_reason("signal", 3600),
1445            ),
1446            (
1447                "daemon_exited",
1448                TeamEvent::daemon_exited("tmux server died", 12, "unrecoverable"),
1449            ),
1450            ("daemon_heartbeat", TeamEvent::daemon_heartbeat(120)),
1451            (
1452                "context_exhausted",
1453                TeamEvent::context_exhausted("eng-1", Some(42), Some(1_024)),
1454            ),
1455            (
1456                "loop_step_error",
1457                TeamEvent::loop_step_error("poll_watchers", "tmux error"),
1458            ),
1459            (
1460                "daemon_panic",
1461                TeamEvent::daemon_panic("index out of bounds"),
1462            ),
1463            ("task_assigned", TeamEvent::task_assigned("eng-1", "task")),
1464            (
1465                "cwd_corrected",
1466                TeamEvent::cwd_corrected("eng-1", "/tmp/worktree"),
1467            ),
1468            (
1469                "task_escalated",
1470                TeamEvent::task_escalated("eng-1", "task", None),
1471            ),
1472            ("task_unblocked", TeamEvent::task_unblocked("eng-1", "task")),
1473            (
1474                "performance_regression",
1475                TeamEvent::performance_regression("42", "runtime_ms=1300 avg_ms=1000 pct=30"),
1476            ),
1477            (
1478                "task_completed",
1479                TeamEvent::task_completed("eng-1", Some("42")),
1480            ),
1481            ("standup_generated", TeamEvent::standup_generated("manager")),
1482            ("retro_generated", TeamEvent::retro_generated()),
1483            (
1484                "pattern_detected",
1485                TeamEvent::pattern_detected("merge_conflict_recurrence", 5),
1486            ),
1487            ("member_crashed", TeamEvent::member_crashed("eng-1", true)),
1488            ("pane_death", TeamEvent::pane_death("eng-1")),
1489            ("pane_respawned", TeamEvent::pane_respawned("eng-1")),
1490            (
1491                "context_pressure_warning",
1492                TeamEvent::context_pressure_warning("eng-1", Some(42), 88, 100, 400_000),
1493            ),
1494            (
1495                "tact_cycle_triggered",
1496                TeamEvent::tact_cycle_triggered("architect", 3, "todo=0, backlog=1"),
1497            ),
1498            (
1499                "tact_tasks_created",
1500                TeamEvent::tact_tasks_created("architect", 2, 14, true, None),
1501            ),
1502            (
1503                "board_task_archived",
1504                TeamEvent::board_task_archived("42", Some("eng-1")),
1505            ),
1506            ("message_routed", TeamEvent::message_routed("a", "b")),
1507            ("agent_spawned", TeamEvent::agent_spawned("eng-1")),
1508            (
1509                "agent_restarted",
1510                TeamEvent::agent_restarted("eng-1", "42", "context_exhausted", 1),
1511            ),
1512            (
1513                "delivery_failed",
1514                TeamEvent::delivery_failed("eng-1", "manager", "message marker missing"),
1515            ),
1516            (
1517                "task_auto_merged",
1518                TeamEvent::task_auto_merged_with_mode(
1519                    "eng-1",
1520                    "42",
1521                    0.95,
1522                    2,
1523                    30,
1524                    Some(MergeMode::IsolatedIntegration),
1525                ),
1526            ),
1527            (
1528                "task_manual_merged",
1529                TeamEvent::task_manual_merged_with_mode("42", Some(MergeMode::DirectRoot)),
1530            ),
1531            (
1532                "task_merge_failed",
1533                TeamEvent::task_merge_failed(
1534                    "eng-1",
1535                    "43",
1536                    Some(MergeMode::IsolatedIntegration),
1537                    "isolated merge path failed: boom",
1538                ),
1539            ),
1540            (
1541                "merge_confidence_scored",
1542                TeamEvent::merge_confidence_scored(&MergeConfidenceInfo {
1543                    engineer: "eng-1",
1544                    task: "42",
1545                    confidence: 0.85,
1546                    files_changed: 3,
1547                    lines_changed: 50,
1548                    has_migrations: false,
1549                    has_config_changes: false,
1550                    rename_count: 0,
1551                }),
1552            ),
1553            (
1554                "review_nudge_sent",
1555                TeamEvent::review_nudge_sent("manager", "42"),
1556            ),
1557            (
1558                "review_escalated",
1559                TeamEvent::review_escalated("42", "stale review"),
1560            ),
1561            (
1562                "pipeline_starvation_detected",
1563                TeamEvent::pipeline_starvation_detected(3, 0),
1564            ),
1565            (
1566                "state_reconciliation",
1567                TeamEvent::state_reconciliation(Some("eng-1"), Some("42"), "adopt"),
1568            ),
1569            ("task_reworked", TeamEvent::task_reworked("eng-1", "42")),
1570            ("load_snapshot", TeamEvent::load_snapshot(2, 5, true)),
1571            (
1572                "parity_updated",
1573                TeamEvent::parity_updated(&crate::team::parity::ParitySummary {
1574                    total_behaviors: 10,
1575                    spec_complete: 8,
1576                    tests_complete: 6,
1577                    implementation_complete: 5,
1578                    verified_pass: 4,
1579                    verified_fail: 1,
1580                    overall_parity_pct: 40,
1581                }),
1582            ),
1583            (
1584                "release_succeeded",
1585                TeamEvent::release_succeeded(
1586                    "0.10.0",
1587                    "abc123",
1588                    "v0.10.0",
1589                    Some("/tmp/v0.10.0.md"),
1590                ),
1591            ),
1592            (
1593                "release_failed",
1594                TeamEvent::release_failed(
1595                    Some("0.10.0"),
1596                    Some("abc123"),
1597                    Some("v0.10.0"),
1598                    "verification failed",
1599                    None,
1600                ),
1601            ),
1602            (
1603                "worktree_reconciled",
1604                TeamEvent::worktree_reconciled("eng-1", "eng-1/42"),
1605            ),
1606        ];
1607        for (expected_event, event) in &variants {
1608            let json = serde_json::to_string(event).unwrap();
1609            let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1610            assert_eq!(parsed["event"].as_str().unwrap(), *expected_event);
1611            assert!(parsed["ts"].as_u64().is_some());
1612        }
1613    }
1614
1615    #[test]
1616    fn load_snapshot_serializes_optional_metrics() {
1617        let event = TeamEvent::load_snapshot(3, 7, false);
1618        let json = serde_json::to_string(&event).unwrap();
1619        assert!(json.contains("\"event\":\"load_snapshot\""));
1620        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1621        assert_eq!(parsed["load"].as_f64().unwrap(), 3.0 / 7.0);
1622        assert_eq!(parsed["working_members"].as_u64().unwrap(), 3);
1623        assert_eq!(parsed["total_members"].as_u64().unwrap(), 7);
1624        assert!(!parsed["session_running"].as_bool().unwrap());
1625    }
1626
1627    #[test]
1628    fn parity_updated_serializes_summary_metrics() {
1629        let event = TeamEvent::parity_updated(&crate::team::parity::ParitySummary {
1630            total_behaviors: 10,
1631            spec_complete: 8,
1632            tests_complete: 6,
1633            implementation_complete: 5,
1634            verified_pass: 4,
1635            verified_fail: 1,
1636            overall_parity_pct: 40,
1637        });
1638        let json = serde_json::to_string(&event).unwrap();
1639        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1640        assert_eq!(parsed["event"].as_str().unwrap(), "parity_updated");
1641        assert_eq!(parsed["load"].as_f64().unwrap(), 0.4);
1642        let reason = parsed["reason"].as_str().unwrap();
1643        assert!(reason.contains("total=10"));
1644        assert!(reason.contains("spec=8"));
1645        assert!(reason.contains("verified_pass=4"));
1646    }
1647
1648    #[test]
1649    fn release_succeeded_serializes_version_and_git_ref() {
1650        let event =
1651            TeamEvent::release_succeeded("0.10.0", "abc123", "v0.10.0", Some("/tmp/v0.10.0.md"));
1652        let json = serde_json::to_string(&event).unwrap();
1653        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1654        assert_eq!(parsed["event"].as_str().unwrap(), "release_succeeded");
1655        assert_eq!(parsed["version"].as_str().unwrap(), "0.10.0");
1656        assert_eq!(parsed["git_ref"].as_str().unwrap(), "abc123");
1657        assert_eq!(parsed["task"].as_str().unwrap(), "v0.10.0");
1658        assert!(parsed["success"].as_bool().unwrap());
1659    }
1660
1661    #[test]
1662    fn release_failed_serializes_reason_and_attempted_tag() {
1663        let event = TeamEvent::release_failed(
1664            Some("0.10.0"),
1665            Some("abc123"),
1666            Some("v0.10.0"),
1667            "verification_failed",
1668            Some("suite::it_breaks"),
1669        );
1670        let json = serde_json::to_string(&event).unwrap();
1671        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1672        assert_eq!(parsed["event"].as_str().unwrap(), "release_failed");
1673        assert_eq!(parsed["version"].as_str().unwrap(), "0.10.0");
1674        assert_eq!(parsed["git_ref"].as_str().unwrap(), "abc123");
1675        assert_eq!(parsed["task"].as_str().unwrap(), "v0.10.0");
1676        assert_eq!(parsed["reason"].as_str().unwrap(), "verification_failed");
1677        assert_eq!(parsed["details"].as_str().unwrap(), "suite::it_breaks");
1678        assert!(!parsed["success"].as_bool().unwrap());
1679    }
1680
1681    #[test]
1682    fn read_events_parses_all_known_lines() {
1683        let tmp = tempfile::tempdir().unwrap();
1684        let path = tmp.path().join("events.jsonl");
1685        let mut sink = EventSink::new(&path).unwrap();
1686        sink.emit(TeamEvent::daemon_started()).unwrap();
1687        sink.emit(TeamEvent::load_snapshot(1, 4, true)).unwrap();
1688        sink.emit(TeamEvent::load_snapshot(2, 4, true)).unwrap();
1689
1690        let events = read_events(&path).unwrap();
1691        assert_eq!(events.len(), 3);
1692        assert_eq!(events[1].event, "load_snapshot");
1693        assert_eq!(events[1].working_members, Some(1));
1694        assert_eq!(events[2].total_members, Some(4));
1695    }
1696
1697    #[test]
1698    fn event_sink_appends_to_existing_file() {
1699        let tmp = tempfile::tempdir().unwrap();
1700        let path = tmp.path().join("events.jsonl");
1701
1702        // Write one event and close the sink
1703        {
1704            let mut sink = EventSink::new(&path).unwrap();
1705            sink.emit(TeamEvent::daemon_started()).unwrap();
1706        }
1707
1708        // Open again and append another
1709        {
1710            let mut sink = EventSink::new(&path).unwrap();
1711            sink.emit(TeamEvent::daemon_stopped()).unwrap();
1712        }
1713
1714        let content = std::fs::read_to_string(&path).unwrap();
1715        let lines: Vec<&str> = content.lines().collect();
1716        assert_eq!(lines.len(), 2);
1717        assert!(lines[0].contains("daemon_started"));
1718        assert!(lines[1].contains("daemon_stopped"));
1719    }
1720
1721    #[test]
1722    fn event_with_special_characters_in_task() {
1723        let event = TeamEvent::task_assigned("eng-1", "fix: \"quotes\" & <angles> / \\ newline\n");
1724        let json = serde_json::to_string(&event).unwrap();
1725        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1726        let task_val = parsed["task"].as_str().unwrap();
1727        assert!(task_val.contains("\"quotes\""));
1728        assert!(task_val.contains("<angles>"));
1729    }
1730
1731    #[test]
1732    fn task_escalated_serializes_role_and_task() {
1733        let event = TeamEvent::task_escalated("eng-1-1", "42", Some("tests_failed"));
1734        let json = serde_json::to_string(&event).unwrap();
1735        assert!(json.contains("\"event\":\"task_escalated\""));
1736        assert!(json.contains("\"role\":\"eng-1-1\""));
1737        assert!(json.contains("\"task\":\"42\""));
1738        assert!(json.contains("\"reason\":\"tests_failed\""));
1739    }
1740
1741    #[test]
1742    fn cwd_corrected_serializes_role_and_reason() {
1743        let event = TeamEvent::cwd_corrected("eng-1-1", "/tmp/worktree");
1744        let json = serde_json::to_string(&event).unwrap();
1745        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1746        assert_eq!(parsed["event"].as_str().unwrap(), "cwd_corrected");
1747        assert_eq!(parsed["role"].as_str().unwrap(), "eng-1-1");
1748        assert_eq!(parsed["reason"].as_str().unwrap(), "/tmp/worktree");
1749    }
1750
1751    #[test]
1752    fn pane_death_serializes_role() {
1753        let event = TeamEvent::pane_death("eng-1-1");
1754        let json = serde_json::to_string(&event).unwrap();
1755        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1756        assert_eq!(parsed["event"].as_str().unwrap(), "pane_death");
1757        assert_eq!(parsed["role"].as_str().unwrap(), "eng-1-1");
1758    }
1759
1760    #[test]
1761    fn pane_respawned_serializes_role() {
1762        let event = TeamEvent::pane_respawned("eng-1-1");
1763        let json = serde_json::to_string(&event).unwrap();
1764        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1765        assert_eq!(parsed["event"].as_str().unwrap(), "pane_respawned");
1766        assert_eq!(parsed["role"].as_str().unwrap(), "eng-1-1");
1767    }
1768
1769    #[test]
1770    fn agent_restarted_includes_reason_task_and_count() {
1771        let event = TeamEvent::agent_restarted("eng-1-2", "67", "context_exhausted", 2);
1772        let json = serde_json::to_string(&event).unwrap();
1773        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1774        assert_eq!(parsed["event"].as_str().unwrap(), "agent_restarted");
1775        assert_eq!(parsed["role"].as_str().unwrap(), "eng-1-2");
1776        assert_eq!(parsed["task"].as_str().unwrap(), "67");
1777        assert_eq!(parsed["reason"].as_str().unwrap(), "context_exhausted");
1778        assert_eq!(parsed["restart_count"].as_u64().unwrap(), 2);
1779    }
1780
1781    #[test]
1782    fn context_pressure_warning_includes_threshold_and_output_bytes() {
1783        let event = TeamEvent::context_pressure_warning("eng-1-2", Some(67), 91, 100, 420_000);
1784        let json = serde_json::to_string(&event).unwrap();
1785        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1786
1787        assert_eq!(
1788            parsed["event"].as_str().unwrap(),
1789            "context_pressure_warning"
1790        );
1791        assert_eq!(parsed["role"].as_str().unwrap(), "eng-1-2");
1792        assert_eq!(parsed["task"].as_str().unwrap(), "67");
1793        assert_eq!(parsed["load"].as_f64().unwrap(), 91.0);
1794        assert_eq!(parsed["output_bytes"].as_u64().unwrap(), 420_000);
1795        assert_eq!(parsed["reason"].as_str().unwrap(), "threshold=100");
1796    }
1797
1798    #[test]
1799    fn tact_tasks_created_includes_latency_status_and_created_count() {
1800        let event = TeamEvent::tact_tasks_created("architect", 4, 19, false, Some("parse failed"));
1801        let json = serde_json::to_string(&event).unwrap();
1802        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1803
1804        assert_eq!(parsed["event"].as_str().unwrap(), "tact_tasks_created");
1805        assert_eq!(parsed["role"].as_str().unwrap(), "architect");
1806        assert_eq!(parsed["restart_count"].as_u64().unwrap(), 4);
1807        assert_eq!(parsed["uptime_secs"].as_u64().unwrap(), 19);
1808        assert_eq!(parsed["reason"].as_str().unwrap(), "failure");
1809        assert_eq!(parsed["error"].as_str().unwrap(), "parse failed");
1810    }
1811
1812    #[test]
1813    fn board_task_archived_includes_task_and_role() {
1814        let event = TeamEvent::board_task_archived("88", Some("eng-1-2"));
1815        let json = serde_json::to_string(&event).unwrap();
1816        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1817
1818        assert_eq!(parsed["event"].as_str().unwrap(), "board_task_archived");
1819        assert_eq!(parsed["task"].as_str().unwrap(), "88");
1820        assert_eq!(parsed["role"].as_str().unwrap(), "eng-1-2");
1821    }
1822
1823    #[test]
1824    fn delivery_failed_includes_role_sender_and_reason() {
1825        let event = TeamEvent::delivery_failed("eng-1-2", "manager", "message marker missing");
1826        let json = serde_json::to_string(&event).unwrap();
1827        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1828        assert_eq!(parsed["event"].as_str().unwrap(), "delivery_failed");
1829        assert_eq!(parsed["role"].as_str().unwrap(), "eng-1-2");
1830        assert_eq!(parsed["from"].as_str().unwrap(), "manager");
1831        assert_eq!(parsed["reason"].as_str().unwrap(), "message marker missing");
1832    }
1833
1834    #[test]
1835    fn delivery_failed_with_details_serializes_actionable_context() {
1836        let event = TeamEvent::delivery_failed_with_details(
1837            "eng-1-2",
1838            "manager",
1839            "recipient has no live shim handle",
1840            Some("classification=missing_shim queue_action=cleared"),
1841        );
1842        let json = serde_json::to_string(&event).unwrap();
1843        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1844        assert_eq!(
1845            parsed["details"].as_str().unwrap(),
1846            "classification=missing_shim queue_action=cleared"
1847        );
1848    }
1849
1850    #[test]
1851    fn task_unblocked_serializes_role_and_task() {
1852        let event = TeamEvent::task_unblocked("eng-1-1", "42");
1853        let json = serde_json::to_string(&event).unwrap();
1854        assert!(json.contains("\"event\":\"task_unblocked\""));
1855        assert!(json.contains("\"role\":\"eng-1-1\""));
1856        assert!(json.contains("\"task\":\"42\""));
1857    }
1858
1859    #[test]
1860    fn merge_confidence_scored_includes_all_fields() {
1861        let event = TeamEvent::merge_confidence_scored(&MergeConfidenceInfo {
1862            engineer: "eng-1-1",
1863            task: "42",
1864            confidence: 0.85,
1865            files_changed: 3,
1866            lines_changed: 50,
1867            has_migrations: true,
1868            has_config_changes: false,
1869            rename_count: 1,
1870        });
1871        let json = serde_json::to_string(&event).unwrap();
1872        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1873        assert_eq!(parsed["event"].as_str().unwrap(), "merge_confidence_scored");
1874        assert_eq!(parsed["role"].as_str().unwrap(), "eng-1-1");
1875        assert_eq!(parsed["task"].as_str().unwrap(), "42");
1876        assert!((parsed["load"].as_f64().unwrap() - 0.85).abs() < 0.001);
1877        let reason = parsed["reason"].as_str().unwrap();
1878        assert!(reason.contains("files=3"));
1879        assert!(reason.contains("lines=50"));
1880        assert!(reason.contains("migrations=true"));
1881        assert!(reason.contains("config=false"));
1882        assert!(reason.contains("renames=1"));
1883    }
1884
1885    #[test]
1886    fn auto_merge_decision_recorded_serializes_reason_and_details() {
1887        let event = TeamEvent::auto_merge_decision_recorded(&AutoMergeDecisionInfo {
1888            engineer: "eng-1-1",
1889            task: "42",
1890            action_type: "manual_review",
1891            confidence: 0.55,
1892            reason: "routed to manual review: confidence 0.55; 4 files, 90 lines, 3 modules; reasons: touches sensitive paths",
1893            details: "{\"decision\":\"manual_review\",\"reasons\":[\"touches sensitive paths\"]}",
1894        });
1895        let json = serde_json::to_string(&event).unwrap();
1896        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1897        assert_eq!(
1898            parsed["event"].as_str().unwrap(),
1899            "auto_merge_decision_recorded"
1900        );
1901        assert_eq!(parsed["role"].as_str().unwrap(), "eng-1-1");
1902        assert_eq!(parsed["task"].as_str().unwrap(), "42");
1903        assert_eq!(parsed["action_type"].as_str().unwrap(), "manual_review");
1904        assert!((parsed["load"].as_f64().unwrap() - 0.55).abs() < 0.001);
1905        assert!(parsed["reason"].as_str().unwrap().contains("manual review"));
1906        assert!(
1907            parsed["details"]
1908                .as_str()
1909                .unwrap()
1910                .contains("\"touches sensitive paths\"")
1911        );
1912    }
1913
1914    #[test]
1915    fn auto_merge_post_verify_result_serializes_success() {
1916        let event = TeamEvent::auto_merge_post_verify_result(
1917            "eng-1",
1918            "42",
1919            Some(false),
1920            "failed",
1921            Some("post-merge verification on main failed"),
1922        );
1923        let json = serde_json::to_string(&event).unwrap();
1924        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1925        assert_eq!(
1926            parsed["event"].as_str().unwrap(),
1927            "auto_merge_post_verify_result"
1928        );
1929        assert_eq!(parsed["role"].as_str().unwrap(), "eng-1");
1930        assert_eq!(parsed["task"].as_str().unwrap(), "42");
1931        assert!(!parsed["success"].as_bool().unwrap());
1932        assert_eq!(parsed["reason"].as_str().unwrap(), "failed");
1933    }
1934
1935    #[test]
1936    fn merge_events_serialize_merge_mode_and_outcome() {
1937        let auto = TeamEvent::task_auto_merged_with_mode(
1938            "eng-1",
1939            "42",
1940            0.95,
1941            2,
1942            30,
1943            Some(MergeMode::DirectRoot),
1944        );
1945        let auto_json: serde_json::Value =
1946            serde_json::from_str(&serde_json::to_string(&auto).unwrap()).unwrap();
1947        assert_eq!(auto_json["merge_mode"].as_str().unwrap(), "direct_root");
1948        assert!(auto_json["success"].as_bool().unwrap());
1949
1950        let manual =
1951            TeamEvent::task_manual_merged_with_mode("43", Some(MergeMode::IsolatedIntegration));
1952        let manual_json: serde_json::Value =
1953            serde_json::from_str(&serde_json::to_string(&manual).unwrap()).unwrap();
1954        assert_eq!(
1955            manual_json["merge_mode"].as_str().unwrap(),
1956            "isolated_integration"
1957        );
1958        assert!(manual_json["success"].as_bool().unwrap());
1959
1960        let failed = TeamEvent::task_merge_failed(
1961            "eng-2",
1962            "44",
1963            Some(MergeMode::IsolatedIntegration),
1964            "isolated merge path failed: broken",
1965        );
1966        let failed_json: serde_json::Value =
1967            serde_json::from_str(&serde_json::to_string(&failed).unwrap()).unwrap();
1968        assert_eq!(
1969            failed_json["merge_mode"].as_str().unwrap(),
1970            "isolated_integration"
1971        );
1972        assert!(!failed_json["success"].as_bool().unwrap());
1973        assert_eq!(failed_json["reason"].as_str().unwrap(), "merge_failed");
1974    }
1975
1976    #[test]
1977    fn performance_regression_serializes_task_and_reason() {
1978        let event = TeamEvent::performance_regression("42", "runtime_ms=1300 avg_ms=1000 pct=30");
1979        let json = serde_json::to_string(&event).unwrap();
1980        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1981        assert_eq!(parsed["event"].as_str().unwrap(), "performance_regression");
1982        assert_eq!(parsed["task"].as_str().unwrap(), "42");
1983        assert_eq!(
1984            parsed["reason"].as_str().unwrap(),
1985            "runtime_ms=1300 avg_ms=1000 pct=30"
1986        );
1987    }
1988
1989    #[test]
1990    fn daemon_stopped_with_reason_includes_fields() {
1991        let event = TeamEvent::daemon_stopped_with_reason("signal", 7200);
1992        let json = serde_json::to_string(&event).unwrap();
1993        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1994        assert_eq!(parsed["reason"].as_str().unwrap(), "signal");
1995        assert_eq!(parsed["uptime_secs"].as_u64().unwrap(), 7200);
1996        assert!(parsed.get("exit_category").is_none());
1997    }
1998
1999    #[test]
2000    fn daemon_exited_includes_reason_and_exit_category() {
2001        let event = TeamEvent::daemon_exited("tmux server died", 9, "unrecoverable");
2002        let json = serde_json::to_string(&event).unwrap();
2003        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2004        assert_eq!(parsed["event"].as_str().unwrap(), "daemon_exited");
2005        assert_eq!(parsed["reason"].as_str().unwrap(), "tmux server died");
2006        assert_eq!(parsed["exit_category"].as_str().unwrap(), "unrecoverable");
2007        assert_eq!(parsed["uptime_secs"].as_u64().unwrap(), 9);
2008    }
2009
2010    #[test]
2011    fn heartbeat_includes_uptime() {
2012        let event = TeamEvent::daemon_heartbeat(600);
2013        let json = serde_json::to_string(&event).unwrap();
2014        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2015        assert_eq!(parsed["event"].as_str().unwrap(), "daemon_heartbeat");
2016        assert_eq!(parsed["uptime_secs"].as_u64().unwrap(), 600);
2017        // No reason/step/error fields
2018        assert!(parsed.get("reason").is_none());
2019        assert!(parsed.get("step").is_none());
2020    }
2021
2022    #[test]
2023    fn context_exhausted_includes_role_task_and_session_size() {
2024        let event = TeamEvent::context_exhausted("eng-1", Some(77), Some(4096));
2025        let json = serde_json::to_string(&event).unwrap();
2026        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2027        assert_eq!(parsed["event"].as_str().unwrap(), "context_exhausted");
2028        assert_eq!(parsed["role"].as_str().unwrap(), "eng-1");
2029        assert_eq!(parsed["task"].as_str().unwrap(), "77");
2030        assert_eq!(parsed["session_size_bytes"].as_u64().unwrap(), 4096);
2031    }
2032
2033    #[test]
2034    fn loop_step_error_includes_step_and_error() {
2035        let event = TeamEvent::loop_step_error("poll_watchers", "connection refused");
2036        let json = serde_json::to_string(&event).unwrap();
2037        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2038        assert_eq!(parsed["step"].as_str().unwrap(), "poll_watchers");
2039        assert_eq!(parsed["error"].as_str().unwrap(), "connection refused");
2040    }
2041
2042    #[test]
2043    fn daemon_panic_includes_reason() {
2044        let event = TeamEvent::daemon_panic("index out of bounds");
2045        let json = serde_json::to_string(&event).unwrap();
2046        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2047        assert_eq!(parsed["event"].as_str().unwrap(), "daemon_panic");
2048        assert_eq!(parsed["reason"].as_str().unwrap(), "index out of bounds");
2049    }
2050
2051    #[test]
2052    fn new_fields_omitted_from_basic_events() {
2053        let event = TeamEvent::daemon_started();
2054        let json = serde_json::to_string(&event).unwrap();
2055        assert!(!json.contains("\"reason\""));
2056        assert!(!json.contains("\"step\""));
2057        assert!(!json.contains("\"error\""));
2058        assert!(!json.contains("\"uptime_secs\""));
2059        assert!(!json.contains("\"restart_count\""));
2060    }
2061
2062    #[test]
2063    fn pattern_detected_includes_reason_payload() {
2064        let event = TeamEvent::pattern_detected("escalation_cluster", 6);
2065        let json = serde_json::to_string(&event).unwrap();
2066        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2067        assert_eq!(parsed["event"].as_str().unwrap(), "pattern_detected");
2068        assert_eq!(parsed["reason"].as_str().unwrap(), "escalation_cluster:6");
2069    }
2070
2071    #[test]
2072    fn event_sink_creates_parent_directories() {
2073        let tmp = tempfile::tempdir().unwrap();
2074        let path = tmp.path().join("deep").join("nested").join("events.jsonl");
2075        let mut sink = EventSink::new(&path).unwrap();
2076        sink.emit(TeamEvent::daemon_started()).unwrap();
2077        assert!(path.exists());
2078        assert_eq!(sink.path(), path);
2079    }
2080
2081    #[test]
2082    fn event_sink_rotates_oversized_log_on_open() {
2083        let tmp = tempfile::tempdir().unwrap();
2084        let path = tmp.path().join("events.jsonl");
2085        fs::write(&path, "0123456789").unwrap();
2086
2087        let mut sink = EventSink::new_with_max_bytes(&path, 5).unwrap();
2088        sink.emit(TeamEvent::daemon_started()).unwrap();
2089
2090        let rotated = rotated_event_log_path(&path);
2091        assert_eq!(fs::read_to_string(&rotated).unwrap(), "0123456789");
2092        let current = fs::read_to_string(&path).unwrap();
2093        assert!(current.contains("daemon_started"));
2094    }
2095
2096    #[test]
2097    fn event_sink_rotates_before_write_that_would_exceed_threshold() {
2098        let tmp = tempfile::tempdir().unwrap();
2099        let path = tmp.path().join("events.jsonl");
2100        let first_line = "{\"event\":\"first\"}\n";
2101        fs::write(&path, first_line).unwrap();
2102
2103        let mut sink = EventSink::new_with_max_bytes(&path, first_line.len() as u64 + 10).unwrap();
2104        sink.emit(TeamEvent::task_assigned(
2105            "eng-1",
2106            "this assignment is long enough to rotate",
2107        ))
2108        .unwrap();
2109
2110        let rotated = rotated_event_log_path(&path);
2111        assert_eq!(fs::read_to_string(&rotated).unwrap(), first_line);
2112        let current = fs::read_to_string(&path).unwrap();
2113        assert!(current.contains("task_assigned"));
2114        assert!(!current.contains("\"event\":\"first\""));
2115    }
2116
2117    #[test]
2118    fn event_round_trip_preserves_fields_for_agent_restarted() {
2119        let original = TeamEvent::agent_restarted("eng-1", "42", "context_exhausted", 3);
2120
2121        let json = serde_json::to_string(&original).unwrap();
2122        let parsed: TeamEvent = serde_json::from_str(&json).unwrap();
2123
2124        assert_eq!(parsed.event, "agent_restarted");
2125        assert_eq!(parsed.role.as_deref(), Some("eng-1"));
2126        assert_eq!(parsed.task.as_deref(), Some("42"));
2127        assert_eq!(parsed.reason.as_deref(), Some("context_exhausted"));
2128        assert_eq!(parsed.restart_count, Some(3));
2129        assert_eq!(parsed.ts, original.ts);
2130    }
2131
2132    #[test]
2133    fn event_round_trip_preserves_fields_for_task_resumed() {
2134        let original = TeamEvent::task_resumed("eng-1", "42", "context_pressure", 1);
2135
2136        let json = serde_json::to_string(&original).unwrap();
2137        let parsed: TeamEvent = serde_json::from_str(&json).unwrap();
2138
2139        assert_eq!(parsed.event, "task_resumed");
2140        assert_eq!(parsed.role.as_deref(), Some("eng-1"));
2141        assert_eq!(parsed.task.as_deref(), Some("42"));
2142        assert_eq!(parsed.reason.as_deref(), Some("context_pressure"));
2143        assert_eq!(parsed.restart_count, Some(1));
2144        assert_eq!(parsed.ts, original.ts);
2145    }
2146
2147    #[test]
2148    fn event_round_trip_preserves_fields_for_load_snapshot() {
2149        let original = TeamEvent::load_snapshot(4, 8, true);
2150
2151        let json = serde_json::to_string(&original).unwrap();
2152        let parsed: TeamEvent = serde_json::from_str(&json).unwrap();
2153
2154        assert_eq!(parsed.event, "load_snapshot");
2155        assert_eq!(parsed.working_members, Some(4));
2156        assert_eq!(parsed.total_members, Some(8));
2157        assert_eq!(parsed.session_running, Some(true));
2158        assert_eq!(parsed.load, Some(0.5));
2159        assert_eq!(parsed.ts, original.ts);
2160    }
2161
2162    #[test]
2163    fn event_round_trip_preserves_fields_for_delivery_failed() {
2164        let original = TeamEvent::delivery_failed("eng-2", "manager", "marker missing");
2165
2166        let json = serde_json::to_string(&original).unwrap();
2167        let parsed: TeamEvent = serde_json::from_str(&json).unwrap();
2168
2169        assert_eq!(parsed.event, "delivery_failed");
2170        assert_eq!(parsed.role.as_deref(), Some("eng-2"));
2171        assert_eq!(parsed.from.as_deref(), Some("manager"));
2172        assert_eq!(parsed.reason.as_deref(), Some("marker missing"));
2173        assert!(parsed.details.is_none());
2174        assert_eq!(parsed.ts, original.ts);
2175    }
2176
2177    #[test]
2178    fn read_events_skips_blank_and_malformed_lines() {
2179        let tmp = tempfile::tempdir().unwrap();
2180        let path = tmp.path().join("events.jsonl");
2181        fs::write(
2182            &path,
2183            [
2184                "",
2185                "{\"event\":\"daemon_started\",\"ts\":1}",
2186                "not-json",
2187                "   ",
2188                "{\"event\":\"daemon_stopped\",\"ts\":2}",
2189            ]
2190            .join("\n"),
2191        )
2192        .unwrap();
2193
2194        let events = read_events(&path).unwrap();
2195
2196        assert_eq!(events.len(), 2);
2197        assert_eq!(events[0].event, "daemon_started");
2198        assert_eq!(events[1].event, "daemon_stopped");
2199    }
2200
2201    #[test]
2202    fn rotate_event_log_if_needed_returns_false_for_missing_file() {
2203        let tmp = tempfile::tempdir().unwrap();
2204        let path = tmp.path().join("events.jsonl");
2205
2206        let rotated = rotate_event_log_if_needed(&path, 128, 0).unwrap();
2207
2208        assert!(!rotated);
2209        assert!(!rotated_event_log_path(&path).exists());
2210    }
2211
2212    #[test]
2213    fn rotate_event_log_if_needed_returns_false_for_empty_file() {
2214        let tmp = tempfile::tempdir().unwrap();
2215        let path = tmp.path().join("events.jsonl");
2216        fs::write(&path, "").unwrap();
2217
2218        let rotated = rotate_event_log_if_needed(&path, 1, 1).unwrap();
2219
2220        assert!(!rotated);
2221        assert!(path.exists());
2222        assert!(!rotated_event_log_path(&path).exists());
2223    }
2224
2225    #[test]
2226    fn rotate_event_log_if_needed_replaces_existing_rotated_file() {
2227        let tmp = tempfile::tempdir().unwrap();
2228        let path = tmp.path().join("events.jsonl");
2229        let rotated_path = rotated_event_log_path(&path);
2230        fs::write(&path, "current-events").unwrap();
2231        fs::write(&rotated_path, "old-rotated-events").unwrap();
2232
2233        let rotated = rotate_event_log_if_needed(&path, 5, 0).unwrap();
2234
2235        assert!(rotated);
2236        assert_eq!(fs::read_to_string(&rotated_path).unwrap(), "current-events");
2237    }
2238
2239    #[test]
2240    fn concurrent_event_sinks_append_without_losing_lines() {
2241        let tmp = tempfile::tempdir().unwrap();
2242        let path = Arc::new(tmp.path().join("events.jsonl"));
2243        let ready = Arc::new(std::sync::Barrier::new(5));
2244        let errors = Arc::new(Mutex::new(Vec::<String>::new()));
2245        let mut handles = Vec::new();
2246
2247        for idx in 0..4 {
2248            let path = Arc::clone(&path);
2249            let ready = Arc::clone(&ready);
2250            let errors = Arc::clone(&errors);
2251            handles.push(thread::spawn(move || {
2252                ready.wait();
2253                let result = (|| -> Result<()> {
2254                    let mut sink = EventSink::new(&path)?;
2255                    sink.emit(TeamEvent::task_assigned(
2256                        &format!("eng-{idx}"),
2257                        &format!("task-{idx}"),
2258                    ))?;
2259                    Ok(())
2260                })();
2261                if let Err(error) = result {
2262                    errors.lock().unwrap().push(error.to_string());
2263                }
2264            }));
2265        }
2266
2267        ready.wait();
2268        for handle in handles {
2269            handle.join().unwrap();
2270        }
2271
2272        assert!(errors.lock().unwrap().is_empty());
2273        let events = read_events(&path).unwrap();
2274        assert_eq!(events.len(), 4);
2275        for idx in 0..4 {
2276            assert!(
2277                events
2278                    .iter()
2279                    .any(|event| event.role.as_deref() == Some(&format!("eng-{idx}")))
2280            );
2281        }
2282    }
2283
2284    #[test]
2285    fn read_events_handles_large_log_file() {
2286        let tmp = tempfile::tempdir().unwrap();
2287        let path = tmp.path().join("events.jsonl");
2288        let mut sink = EventSink::new(&path).unwrap();
2289
2290        for idx in 0..512 {
2291            sink.emit(TeamEvent::task_assigned(
2292                &format!("eng-{idx}"),
2293                &"x".repeat(128),
2294            ))
2295            .unwrap();
2296        }
2297
2298        let events = read_events(&path).unwrap();
2299
2300        assert_eq!(events.len(), 512);
2301        assert_eq!(events.first().unwrap().event, "task_assigned");
2302        assert_eq!(events.last().unwrap().event, "task_assigned");
2303    }
2304
2305    fn production_unwrap_expect_count(source: &str) -> usize {
2306        let prod = if let Some(pos) = source.find("\n#[cfg(test)]\nmod tests") {
2307            &source[..pos]
2308        } else {
2309            source
2310        };
2311        prod.lines()
2312            .filter(|line| {
2313                let trimmed = line.trim();
2314                !trimmed.starts_with("#[cfg(test)]")
2315                    && (trimmed.contains(".unwrap(") || trimmed.contains(".expect("))
2316            })
2317            .count()
2318    }
2319
2320    #[test]
2321    fn task_completed_includes_task_id() {
2322        let event = TeamEvent::task_completed("eng-1", Some("42"));
2323        let json = serde_json::to_string(&event).unwrap();
2324        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2325        assert_eq!(parsed["event"].as_str().unwrap(), "task_completed");
2326        assert_eq!(parsed["role"].as_str().unwrap(), "eng-1");
2327        assert_eq!(parsed["task"].as_str().unwrap(), "42");
2328    }
2329
2330    #[test]
2331    fn task_completed_without_task_id_omits_task_field() {
2332        let event = TeamEvent::task_completed("eng-1", None);
2333        let json = serde_json::to_string(&event).unwrap();
2334        assert!(json.contains("\"event\":\"task_completed\""));
2335        assert!(json.contains("\"role\":\"eng-1\""));
2336        assert!(!json.contains("\"task\""));
2337    }
2338
2339    #[test]
2340    fn task_escalated_without_reason_omits_reason_field() {
2341        let event = TeamEvent::task_escalated("eng-1", "42", None);
2342        let json = serde_json::to_string(&event).unwrap();
2343        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2344        assert_eq!(parsed["event"].as_str().unwrap(), "task_escalated");
2345        assert_eq!(parsed["task"].as_str().unwrap(), "42");
2346        assert!(parsed.get("reason").is_none());
2347    }
2348
2349    #[test]
2350    fn task_escalated_with_reason_includes_reason_field() {
2351        let event = TeamEvent::task_escalated("eng-1", "42", Some("merge_conflict"));
2352        let json = serde_json::to_string(&event).unwrap();
2353        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2354        assert_eq!(parsed["event"].as_str().unwrap(), "task_escalated");
2355        assert_eq!(parsed["task"].as_str().unwrap(), "42");
2356        assert_eq!(parsed["reason"].as_str().unwrap(), "merge_conflict");
2357    }
2358
2359    #[test]
2360    fn task_completed_round_trip_preserves_task_id() {
2361        let original = TeamEvent::task_completed("eng-1", Some("99"));
2362        let json = serde_json::to_string(&original).unwrap();
2363        let parsed: TeamEvent = serde_json::from_str(&json).unwrap();
2364        assert_eq!(parsed.event, "task_completed");
2365        assert_eq!(parsed.role.as_deref(), Some("eng-1"));
2366        assert_eq!(parsed.task.as_deref(), Some("99"));
2367    }
2368
2369    #[test]
2370    fn task_escalated_round_trip_preserves_reason() {
2371        let original = TeamEvent::task_escalated("eng-1", "42", Some("context_exhausted"));
2372        let json = serde_json::to_string(&original).unwrap();
2373        let parsed: TeamEvent = serde_json::from_str(&json).unwrap();
2374        assert_eq!(parsed.event, "task_escalated");
2375        assert_eq!(parsed.role.as_deref(), Some("eng-1"));
2376        assert_eq!(parsed.task.as_deref(), Some("42"));
2377        assert_eq!(parsed.reason.as_deref(), Some("context_exhausted"));
2378    }
2379
2380    #[test]
2381    fn production_events_has_no_unwrap_or_expect_calls() {
2382        let src = include_str!("events.rs");
2383        assert_eq!(
2384            production_unwrap_expect_count(src),
2385            0,
2386            "production events.rs should avoid unwrap/expect"
2387        );
2388    }
2389
2390    #[test]
2391    fn stall_detected_event_fields() {
2392        let event = TeamEvent::stall_detected("eng-1-1", Some(42), 300);
2393        assert_eq!(event.event, "stall_detected");
2394        assert_eq!(event.role.as_deref(), Some("eng-1-1"));
2395        assert_eq!(event.task.as_deref(), Some("42"));
2396        assert_eq!(event.uptime_secs, Some(300));
2397    }
2398
2399    #[test]
2400    fn stall_detected_event_without_task() {
2401        let event = TeamEvent::stall_detected("eng-1-1", None, 600);
2402        assert_eq!(event.event, "stall_detected");
2403        assert_eq!(event.role.as_deref(), Some("eng-1-1"));
2404        assert!(event.task.is_none());
2405        assert_eq!(event.uptime_secs, Some(600));
2406    }
2407
2408    #[test]
2409    fn stall_detected_event_with_reason_records_heuristic() {
2410        let event = TeamEvent::stall_detected_with_reason(
2411            "lead",
2412            None,
2413            300,
2414            Some("supervisory_stalled_manager_shim_activity_only"),
2415        );
2416        assert_eq!(
2417            event.reason.as_deref(),
2418            Some("supervisory_stalled_manager_shim_activity_only")
2419        );
2420    }
2421
2422    #[test]
2423    fn stall_detected_event_serializes_to_jsonl() {
2424        let event = TeamEvent::stall_detected("eng-1-1", Some(42), 300);
2425        let json = serde_json::to_string(&event).unwrap();
2426        assert!(json.contains("\"stall_detected\""));
2427        assert!(json.contains("\"eng-1-1\""));
2428        assert!(json.contains("\"42\""));
2429    }
2430
2431    #[test]
2432    fn health_changed_event_fields() {
2433        let event = TeamEvent::health_changed("eng-1-1", "healthy→unreachable");
2434        assert_eq!(event.event, "health_changed");
2435        assert_eq!(event.role.as_deref(), Some("eng-1-1"));
2436        assert_eq!(event.reason.as_deref(), Some("healthy→unreachable"));
2437    }
2438
2439    #[test]
2440    fn health_changed_event_serializes_to_jsonl() {
2441        let event = TeamEvent::health_changed("eng-1-2", "unreachable→healthy");
2442        let json = serde_json::to_string(&event).unwrap();
2443        assert!(json.contains("\"health_changed\""));
2444        assert!(json.contains("\"eng-1-2\""));
2445    }
2446
2447    // --- Error path and recovery tests (Task #265) ---
2448
2449    #[test]
2450    fn event_sink_on_readonly_dir_returns_error() {
2451        #[cfg(unix)]
2452        {
2453            use std::os::unix::fs::PermissionsExt;
2454            let tmp = tempfile::tempdir().unwrap();
2455            let readonly_dir = tmp.path().join("readonly");
2456            fs::create_dir(&readonly_dir).unwrap();
2457            fs::set_permissions(&readonly_dir, fs::Permissions::from_mode(0o444)).unwrap();
2458
2459            let path = readonly_dir.join("subdir").join("events.jsonl");
2460            let result = EventSink::new(&path);
2461            assert!(result.is_err());
2462
2463            // Restore permissions for cleanup
2464            fs::set_permissions(&readonly_dir, fs::Permissions::from_mode(0o755)).unwrap();
2465        }
2466    }
2467
2468    #[test]
2469    fn read_events_from_nonexistent_file_returns_empty() {
2470        let tmp = tempfile::tempdir().unwrap();
2471        let path = tmp.path().join("does_not_exist.jsonl");
2472        let events = read_events(&path).unwrap();
2473        assert!(events.is_empty());
2474    }
2475
2476    #[test]
2477    fn read_events_all_malformed_lines_returns_empty() {
2478        let tmp = tempfile::tempdir().unwrap();
2479        let path = tmp.path().join("events.jsonl");
2480        fs::write(&path, "not json\nalso not json\n{invalid}\n").unwrap();
2481        let events = read_events(&path).unwrap();
2482        assert!(events.is_empty());
2483    }
2484
2485    #[test]
2486    fn event_sink_emit_with_failing_writer() {
2487        struct FailWriter;
2488        impl Write for FailWriter {
2489            fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
2490                Err(std::io::Error::new(
2491                    std::io::ErrorKind::BrokenPipe,
2492                    "simulated write failure",
2493                ))
2494            }
2495            fn flush(&mut self) -> std::io::Result<()> {
2496                Err(std::io::Error::new(
2497                    std::io::ErrorKind::BrokenPipe,
2498                    "simulated flush failure",
2499                ))
2500            }
2501        }
2502
2503        let tmp = tempfile::tempdir().unwrap();
2504        let path = tmp.path().join("events.jsonl");
2505        let mut sink = EventSink::from_writer(&path, FailWriter);
2506        let result = sink.emit(TeamEvent::daemon_started());
2507        assert!(result.is_err());
2508    }
2509
2510    #[test]
2511    fn rotate_event_log_replaces_stale_rotated_file() {
2512        let tmp = tempfile::tempdir().unwrap();
2513        let path = tmp.path().join("events.jsonl");
2514        let rotated = rotated_event_log_path(&path);
2515
2516        fs::write(&path, "current-data-that-is-large").unwrap();
2517        fs::write(&rotated, "old-rotated-data").unwrap();
2518
2519        let did_rotate = rotate_event_log_if_needed(&path, 5, 0).unwrap();
2520        assert!(did_rotate);
2521        // Old rotated was replaced with current data
2522        assert_eq!(
2523            fs::read_to_string(&rotated).unwrap(),
2524            "current-data-that-is-large"
2525        );
2526        // Current file is now gone (rotated away)
2527        assert!(!path.exists());
2528    }
2529
2530    #[test]
2531    fn event_sink_handles_zero_max_bytes_rotation() {
2532        let tmp = tempfile::tempdir().unwrap();
2533        let path = tmp.path().join("events.jsonl");
2534
2535        // With max_bytes=0, any existing content triggers rotation, but empty file doesn't
2536        fs::write(&path, "").unwrap();
2537        let did_rotate = rotate_event_log_if_needed(&path, 0, 0).unwrap();
2538        assert!(!did_rotate); // empty file → no rotation
2539
2540        fs::write(&path, "x").unwrap();
2541        let did_rotate = rotate_event_log_if_needed(&path, 0, 0).unwrap();
2542        assert!(did_rotate); // non-empty file at 0-byte limit → rotation
2543    }
2544
2545    #[test]
2546    fn narration_rejection_event_has_correct_fields() {
2547        let event = TeamEvent::narration_rejection("eng-1-1", 42, 2);
2548        assert_eq!(event.event, "narration_rejection");
2549        assert_eq!(event.role.as_deref(), Some("eng-1-1"));
2550        assert_eq!(event.task.as_deref(), Some("42"));
2551        assert_eq!(event.reason.as_deref(), Some("rejection_count=2"));
2552    }
2553
2554    #[test]
2555    fn narration_events_have_correct_fields() {
2556        let detected = TeamEvent::narration_detected("eng-1-4", Some(415));
2557        assert_eq!(detected.event, "narration_detected");
2558        assert_eq!(detected.role.as_deref(), Some("eng-1-4"));
2559        assert_eq!(detected.task.as_deref(), Some("415"));
2560
2561        let nudged = TeamEvent::narration_nudged("eng-1-4", Some(415));
2562        assert_eq!(nudged.event, "narration_nudged");
2563        assert_eq!(nudged.role.as_deref(), Some("eng-1-4"));
2564        assert_eq!(nudged.task.as_deref(), Some("415"));
2565
2566        let restarted = TeamEvent::narration_restart("eng-1-4", Some(415));
2567        assert_eq!(restarted.event, "narration_restart");
2568        assert_eq!(restarted.role.as_deref(), Some("eng-1-4"));
2569        assert_eq!(restarted.task.as_deref(), Some("415"));
2570    }
2571
2572    #[test]
2573    fn read_events_partial_json_with_valid_lines_mixed() {
2574        let tmp = tempfile::tempdir().unwrap();
2575        let path = tmp.path().join("events.jsonl");
2576        // Simulate a truncated write: valid JSON, then partial, then valid
2577        let content = format!(
2578            "{}\n{{\"event\":\"trunca\n{}\n",
2579            r#"{"event":"daemon_started","ts":1}"#, r#"{"event":"daemon_stopped","ts":3}"#
2580        );
2581        fs::write(&path, content).unwrap();
2582
2583        let events = read_events(&path).unwrap();
2584        assert_eq!(events.len(), 2);
2585        assert_eq!(events[0].event, "daemon_started");
2586        assert_eq!(events[1].event, "daemon_stopped");
2587    }
2588
2589    // -----------------------------------------------------------------------
2590    // Lifecycle event tests
2591    // -----------------------------------------------------------------------
2592
2593    #[test]
2594    fn session_lifecycle_serialization_roundtrip() {
2595        let variants = [
2596            SessionLifecycle::Spawning,
2597            SessionLifecycle::Ready,
2598            SessionLifecycle::Working,
2599            SessionLifecycle::Blocked,
2600            SessionLifecycle::Idle,
2601            SessionLifecycle::Finished,
2602            SessionLifecycle::Failed,
2603        ];
2604        for variant in &variants {
2605            let json = serde_json::to_string(variant).unwrap();
2606            let deserialized: SessionLifecycle = serde_json::from_str(&json).unwrap();
2607            assert_eq!(*variant, deserialized);
2608        }
2609    }
2610
2611    #[test]
2612    fn task_lifecycle_serialization_roundtrip() {
2613        let variants = [
2614            TaskLifecycle::Claimed,
2615            TaskLifecycle::InProgress,
2616            TaskLifecycle::TestsRunning,
2617            TaskLifecycle::TestsPassed,
2618            TaskLifecycle::TestsFailed,
2619            TaskLifecycle::Review,
2620            TaskLifecycle::Merged,
2621            TaskLifecycle::Rejected,
2622        ];
2623        for variant in &variants {
2624            let json = serde_json::to_string(variant).unwrap();
2625            let deserialized: TaskLifecycle = serde_json::from_str(&json).unwrap();
2626            assert_eq!(*variant, deserialized);
2627        }
2628    }
2629
2630    #[test]
2631    fn merge_lifecycle_serialization_roundtrip() {
2632        let variants = [
2633            MergeLifecycle::Started,
2634            MergeLifecycle::Conflict,
2635            MergeLifecycle::Success,
2636        ];
2637        for variant in &variants {
2638            let json = serde_json::to_string(variant).unwrap();
2639            let deserialized: MergeLifecycle = serde_json::from_str(&json).unwrap();
2640            assert_eq!(*variant, deserialized);
2641        }
2642    }
2643
2644    #[test]
2645    fn lifecycle_event_serialization_roundtrip() {
2646        let event = LifecycleEvent::new(
2647            LifecycleEventType::Session(SessionLifecycle::Ready),
2648            "eng-1-1",
2649            Some(42),
2650            Some("agent is ready".into()),
2651        );
2652        let json = serde_json::to_string(&event).unwrap();
2653        let deserialized: LifecycleEvent = serde_json::from_str(&json).unwrap();
2654
2655        assert_eq!(deserialized.schema_version, LifecycleEvent::SCHEMA_VERSION);
2656        assert_eq!(deserialized.agent_name, "eng-1-1");
2657        assert_eq!(deserialized.task_id, Some(42));
2658        assert_eq!(deserialized.details.as_deref(), Some("agent is ready"));
2659        assert_eq!(
2660            deserialized.event_type,
2661            LifecycleEventType::Session(SessionLifecycle::Ready)
2662        );
2663    }
2664
2665    #[test]
2666    fn lifecycle_event_optional_fields_omitted() {
2667        let event = LifecycleEvent::new(
2668            LifecycleEventType::Task(TaskLifecycle::Claimed),
2669            "eng-1-2",
2670            None,
2671            None,
2672        );
2673        let json = serde_json::to_string(&event).unwrap();
2674        assert!(!json.contains("task_id"));
2675        assert!(!json.contains("details"));
2676        assert!(json.contains("\"agent_name\":\"eng-1-2\""));
2677    }
2678
2679    #[test]
2680    fn lifecycle_event_type_tagged_serialization() {
2681        // Session variant
2682        let session = LifecycleEventType::Session(SessionLifecycle::Working);
2683        let json = serde_json::to_string(&session).unwrap();
2684        assert!(json.contains("\"category\":\"session\""));
2685        assert!(json.contains("\"phase\":\"working\""));
2686
2687        // Task variant
2688        let task = LifecycleEventType::Task(TaskLifecycle::TestsRunning);
2689        let json = serde_json::to_string(&task).unwrap();
2690        assert!(json.contains("\"category\":\"task\""));
2691        assert!(json.contains("\"phase\":\"tests_running\""));
2692
2693        // Merge variant
2694        let merge = LifecycleEventType::Merge(MergeLifecycle::Conflict);
2695        let json = serde_json::to_string(&merge).unwrap();
2696        assert!(json.contains("\"category\":\"merge\""));
2697        assert!(json.contains("\"phase\":\"conflict\""));
2698    }
2699
2700    #[test]
2701    fn emit_lifecycle_event_writes_to_file() {
2702        let tmp = tempfile::tempdir().unwrap();
2703        let path = tmp.path().join("events.jsonl");
2704
2705        let event = LifecycleEvent::new(
2706            LifecycleEventType::Session(SessionLifecycle::Spawning),
2707            "eng-1-1",
2708            None,
2709            Some("starting up".into()),
2710        );
2711        emit_lifecycle_event(&path, &event).unwrap();
2712
2713        let content = fs::read_to_string(&path).unwrap();
2714        let lines: Vec<&str> = content.lines().collect();
2715        assert_eq!(lines.len(), 1);
2716
2717        let parsed: LifecycleEvent = serde_json::from_str(lines[0]).unwrap();
2718        assert_eq!(parsed.agent_name, "eng-1-1");
2719        assert_eq!(
2720            parsed.event_type,
2721            LifecycleEventType::Session(SessionLifecycle::Spawning)
2722        );
2723    }
2724
2725    #[test]
2726    fn emit_lifecycle_event_appends_multiple() {
2727        let tmp = tempfile::tempdir().unwrap();
2728        let path = tmp.path().join("events.jsonl");
2729
2730        let events = [
2731            LifecycleEvent::new(
2732                LifecycleEventType::Task(TaskLifecycle::Claimed),
2733                "eng-1-1",
2734                Some(100),
2735                None,
2736            ),
2737            LifecycleEvent::new(
2738                LifecycleEventType::Task(TaskLifecycle::InProgress),
2739                "eng-1-1",
2740                Some(100),
2741                None,
2742            ),
2743            LifecycleEvent::new(
2744                LifecycleEventType::Task(TaskLifecycle::TestsPassed),
2745                "eng-1-1",
2746                Some(100),
2747                Some("all 42 tests passed".into()),
2748            ),
2749        ];
2750        for event in &events {
2751            emit_lifecycle_event(&path, event).unwrap();
2752        }
2753
2754        let content = fs::read_to_string(&path).unwrap();
2755        let lines: Vec<&str> = content.lines().filter(|l| !l.is_empty()).collect();
2756        assert_eq!(lines.len(), 3);
2757
2758        let parsed: LifecycleEvent = serde_json::from_str(lines[2]).unwrap();
2759        assert_eq!(
2760            parsed.event_type,
2761            LifecycleEventType::Task(TaskLifecycle::TestsPassed)
2762        );
2763        assert_eq!(parsed.details.as_deref(), Some("all 42 tests passed"));
2764    }
2765
2766    #[test]
2767    fn emit_lifecycle_event_creates_parent_dirs() {
2768        let tmp = tempfile::tempdir().unwrap();
2769        let path = tmp.path().join("nested").join("dir").join("events.jsonl");
2770
2771        let event = LifecycleEvent::new(
2772            LifecycleEventType::Merge(MergeLifecycle::Success),
2773            "eng-1-3",
2774            Some(200),
2775            None,
2776        );
2777        emit_lifecycle_event(&path, &event).unwrap();
2778        assert!(path.exists());
2779    }
2780
2781    #[test]
2782    fn lifecycle_event_schema_version_is_one() {
2783        let event = LifecycleEvent::new(
2784            LifecycleEventType::Session(SessionLifecycle::Failed),
2785            "eng-1-1",
2786            None,
2787            Some("out of context".into()),
2788        );
2789        assert_eq!(event.schema_version, 1);
2790        let json = serde_json::to_string(&event).unwrap();
2791        assert!(json.contains("\"schema_version\":1"));
2792    }
2793
2794    #[test]
2795    fn all_session_variants_snake_case_names() {
2796        let expected = [
2797            (SessionLifecycle::Spawning, "spawning"),
2798            (SessionLifecycle::Ready, "ready"),
2799            (SessionLifecycle::Working, "working"),
2800            (SessionLifecycle::Blocked, "blocked"),
2801            (SessionLifecycle::Idle, "idle"),
2802            (SessionLifecycle::Finished, "finished"),
2803            (SessionLifecycle::Failed, "failed"),
2804        ];
2805        for (variant, name) in &expected {
2806            let json = serde_json::to_string(variant).unwrap();
2807            assert_eq!(json, format!("\"{}\"", name));
2808        }
2809    }
2810
2811    #[test]
2812    fn all_task_variants_snake_case_names() {
2813        let expected = [
2814            (TaskLifecycle::Claimed, "claimed"),
2815            (TaskLifecycle::InProgress, "in_progress"),
2816            (TaskLifecycle::TestsRunning, "tests_running"),
2817            (TaskLifecycle::TestsPassed, "tests_passed"),
2818            (TaskLifecycle::TestsFailed, "tests_failed"),
2819            (TaskLifecycle::Review, "review"),
2820            (TaskLifecycle::Merged, "merged"),
2821            (TaskLifecycle::Rejected, "rejected"),
2822        ];
2823        for (variant, name) in &expected {
2824            let json = serde_json::to_string(variant).unwrap();
2825            assert_eq!(json, format!("\"{}\"", name));
2826        }
2827    }
2828
2829    #[test]
2830    fn all_merge_variants_snake_case_names() {
2831        let expected = [
2832            (MergeLifecycle::Started, "started"),
2833            (MergeLifecycle::Conflict, "conflict"),
2834            (MergeLifecycle::Success, "success"),
2835        ];
2836        for (variant, name) in &expected {
2837            let json = serde_json::to_string(variant).unwrap();
2838            assert_eq!(json, format!("\"{}\"", name));
2839        }
2840    }
2841}