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