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