1use crate::{
2 config::{AppConfig, SessionRetentionConfig},
3 events::{Engine, Event},
4 github::{split_repo, CheckRun},
5 hooks,
6 lifecycle::{
7 brain_harvest::{self, ClaudeHarvestRunner, HarvestRunner},
8 enrichment::EnrichmentCache,
9 probe::is_pid_alive,
10 update_check::{self, CargoRegistryUpdateSource, UpdateSource},
11 usage,
12 },
13 types::{
14 CIStatus, Comment, GateCheck, GateStatus, Notification, NotificationKind, PrId, Session,
15 SessionFields, SessionStatus, PR,
16 },
17};
18use std::{
19 collections::HashMap,
20 path::{Path, PathBuf},
21 sync::Arc,
22 time::Duration,
23};
24use tokio_util::sync::CancellationToken;
25
26type ContextSnapshot = (f64, Option<f64>, Option<u64>);
29
30pub(crate) fn now_millis() -> i64 {
34 std::time::SystemTime::now()
35 .duration_since(std::time::UNIX_EPOCH)
36 .map(|d| d.as_millis() as i64)
37 .unwrap_or(0)
38}
39
40fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
44 if let Some(s) = payload.downcast_ref::<&str>() {
45 s.to_string()
46 } else if let Some(s) = payload.downcast_ref::<String>() {
47 s.clone()
48 } else {
49 "<non-string panic payload>".to_string()
50 }
51}
52
53pub struct Poller {
54 engine: Arc<Engine>,
55 enrichment_cache: Arc<std::sync::Mutex<EnrichmentCache>>,
56 context_cache: Arc<std::sync::Mutex<HashMap<String, ContextSnapshot>>>,
60 harvest_runner: Arc<dyn HarvestRunner>,
64 vault_locks: Arc<std::sync::Mutex<HashMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>>,
72 update_source: Arc<dyn UpdateSource>,
75 last_notified_update: Arc<std::sync::Mutex<Option<semver::Version>>>,
79}
80
81impl Poller {
82 pub fn new(engine: Arc<Engine>) -> Self {
83 Self::new_with_harvest_runner(engine, Arc::new(ClaudeHarvestRunner))
84 }
85
86 pub fn new_with_harvest_runner(engine: Arc<Engine>, harvest_runner: Arc<dyn HarvestRunner>) -> Self {
87 Self {
88 engine,
89 enrichment_cache: Arc::new(std::sync::Mutex::new(HashMap::new())),
90 context_cache: Arc::new(std::sync::Mutex::new(HashMap::new())),
91 harvest_runner,
92 vault_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
93 update_source: Arc::new(CargoRegistryUpdateSource),
94 last_notified_update: Arc::new(std::sync::Mutex::new(None)),
95 }
96 }
97
98 pub fn with_update_source(mut self, source: Arc<dyn UpdateSource>) -> Self {
101 self.update_source = source;
102 self
103 }
104
105 fn vault_lock(&self, path: &Path) -> Arc<tokio::sync::Mutex<()>> {
108 let key = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
115 let mut locks = self.vault_locks.lock().unwrap_or_else(|e| e.into_inner());
116 locks
117 .entry(key)
118 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
119 .clone()
120 }
121
122 pub async fn start(self, token: CancellationToken) {
123 let mut pid_interval = tokio::time::interval(Duration::from_secs(5));
124 let mut usage_interval = tokio::time::interval(Duration::from_secs(10));
125 let mut github_interval = tokio::time::interval(Duration::from_secs(30));
126 let mut update_interval = tokio::time::interval(Duration::from_secs(6 * 60 * 60));
131 github_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
133 usage_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
134 update_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
135 loop {
136 tokio::select! {
137 _ = token.cancelled() => break,
138 _ = pid_interval.tick() => {
139 self.poll_pids().await;
140 self.poll_context_updates().await;
141 let retention = AppConfig::load()
142 .unwrap_or_default()
143 .session_retention;
144 self.sweep_retired_sessions(&retention).await;
145 }
146 _ = usage_interval.tick() => self.poll_usage().await,
147 _ = update_interval.tick() => self.poll_update_check().await,
148 _ = github_interval.tick() => {
149 self.poll_pr_reconciliation().await;
153 self.poll_github().await;
154 }
155 }
156 }
157 }
158
159 async fn poll_update_check(&self) {
170 let current_version = env!("CARGO_PKG_VERSION");
171 match update_check::check_for_update(self.update_source.as_ref(), "ninox", current_version).await {
172 Ok(Some(latest)) => {
173 let already_notified = {
174 let mut last = self.last_notified_update.lock().unwrap_or_else(|e| e.into_inner());
175 let already = last.as_ref() == Some(&latest);
176 *last = Some(latest.clone());
177 already
178 };
179 if !already_notified {
180 self.engine.emit(Event::Notification(Notification {
181 id: format!("update-available-{latest}"),
182 kind: NotificationKind::UpdateAvailable,
183 title: "Update available".to_string(),
184 body: format!("ninox {latest} is available — running {current_version}"),
185 session_id: None,
186 created_at: now_millis(),
187 }));
188 }
189 }
190 Ok(None) => {}
191 Err(e) => tracing::warn!("update check failed: {e}"),
192 }
193 }
194
195 async fn poll_pids(&self) {
198 self.sync_sessions_metadata(&AppConfig::sessions_dir()).await;
201
202 let Ok(sessions) = self.engine.store.list_sessions() else { return };
203 for mut session in sessions {
204 if matches!(session.status, SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted) {
205 continue;
206 }
207 if let Some(pid) = session.pid {
208 if !is_pid_alive(pid) {
209 session.status = SessionStatus::Terminated;
210 session.terminal_at = Some(now_millis());
211 let _ = self.engine.store.upsert_session(&session);
212 self.engine.emit(Event::SessionUpdated(session, SessionFields::STATUS | SessionFields::TERMINAL_AT));
213 }
214 }
215 }
216 }
217
218 async fn sync_sessions_metadata(&self, sessions_dir: &std::path::Path) {
226 let Ok(sessions) = self.engine.store.list_sessions() else { return };
227 for mut session in sessions {
228 self.deliver_work_requests(&session, sessions_dir).await;
232
233 if matches!(session.status, SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted) {
234 continue;
235 }
236 let Ok(meta) = hooks::read_session_metadata(sessions_dir, &session.id) else {
237 continue;
238 };
239
240 if session.pr_number.is_none() {
242 if let Some(first) = meta.pr_reports.first() {
243 session.pr_number = Some(first.number);
244 session.status = SessionStatus::PrOpen;
245 let _ = self.engine.store.upsert_session(&session);
246 self.engine.emit(Event::SessionUpdated(
247 session.clone(), SessionFields::PR_LINK | SessionFields::STATUS,
248 ));
249 tracing::info!(
250 "session {} PR #{} detected via metadata hook",
251 session.id, first.number
252 );
253 self.trigger_brain_harvest(&session).await;
254 }
255 }
256
257 let Some(tracked) = session.pr_number else { continue };
259
260 for report in meta.pr_reports.iter().filter(|r| r.number != tracked) {
265 if let Ok(None) = self.engine.store.get_pr(report.number as i64) {
266 let url = report.url.clone().unwrap_or_else(|| {
267 format!("https://github.com/{}/pull/{}", session.repo, report.number)
268 });
269 let pr = PR {
270 id: report.number as i64,
271 number: report.number,
272 title: String::new(),
273 url,
274 body: String::new(),
275 session_id: session.id.clone(),
276 };
277 let _ = self.engine.store.upsert_pr(&pr);
278 self.engine.emit(Event::ExtraPrDetected(pr));
284 }
285 }
286
287 let notified = hooks::read_notified_extra_prs(sessions_dir, &session.id);
289 let mut fresh: Vec<(u64, Option<String>)> = Vec::new();
290 for report in meta.pr_reports.iter()
291 .filter(|r| r.number != tracked && !notified.contains(&r.number))
292 {
293 self.engine.emit(Event::Notification(Notification {
294 id: format!("extra-pr-{}-{}", session.id, report.number),
295 kind: NotificationKind::ExtraPr,
296 title: format!("Extra PR — {}", session.name),
297 body: format!("#{} opened beyond tracked #{tracked}", report.number),
298 session_id: Some(session.id.clone()),
299 created_at: now_millis(),
300 }));
301 fresh.push((report.number, report.url.clone()));
302 }
303 if fresh.is_empty() {
304 continue;
305 }
306 let numbers: Vec<u64> = fresh.iter().map(|(n, _)| *n).collect();
307 if let Err(e) = hooks::mark_extra_prs_notified(sessions_dir, &session.id, &numbers) {
308 tracing::warn!("mark extra PRs notified for {}: {e}", session.id);
309 }
310 if let Some(orch) = session.orchestrator_id.clone() {
311 let msg = crate::lifecycle::reactions::format_extra_pr_reaction(
312 &session, tracked, &fresh,
313 );
314 if let Err(e) = self.engine.send_to_session(&orch, &msg).await {
315 tracing::warn!("send extra-PR reaction to orchestrator {orch}: {e}");
316 }
317 }
318 }
319 }
320
321 async fn trigger_brain_harvest(&self, session: &Session) {
336 let config = AppConfig::load().unwrap_or_default();
337 if !config.brain_harvest.enabled {
338 return;
339 }
340 let Some(workspace) = session.workspace_path.clone() else { return };
341 let workspace_path: PathBuf = workspace.into();
342 let brain_path: PathBuf = session.catalogue_path.clone()
348 .map(PathBuf::from)
349 .unwrap_or_else(|| config.resolved_brain_path());
350 let runner = self.harvest_runner.clone();
351 let session_id = session.id.clone();
352 let panic_session_id = session_id.clone();
353 let vault_lock = self.vault_lock(&brain_path);
354
355 let handle = tokio::spawn(async move {
356 let Some(diff) = brain_harvest::compute_nontrivial_diff(&workspace_path).await else {
357 tracing::info!("brain harvest skipped for {session_id}: no non-trivial diff");
358 return;
359 };
360 let prompt = brain_harvest::build_harvest_prompt(&session_id, &diff);
361
362 let _guard = vault_lock.lock().await;
366 if let Err(e) = runner.run(prompt, workspace_path, brain_path).await {
367 tracing::warn!("brain harvest failed for session {session_id}: {e}");
368 }
369 });
370
371 tokio::spawn(async move {
372 if let Err(join_err) = handle.await {
373 if join_err.is_panic() {
374 let payload = join_err.into_panic();
375 tracing::warn!(
376 "brain harvest task panicked for session {panic_session_id}: {}",
377 panic_message(payload.as_ref()),
378 );
379 } else {
380 tracing::warn!("brain harvest task for session {panic_session_id} was cancelled");
381 }
382 }
383 });
384 }
385
386 async fn deliver_work_requests(&self, session: &crate::types::Session, sessions_dir: &std::path::Path) {
389 let pending = match hooks::read_pending_work_requests(sessions_dir, &session.id) {
390 Ok(p) if !p.is_empty() => p,
391 _ => return,
392 };
393 for request in &pending {
394 self.engine.emit(Event::Notification(Notification {
395 id: format!("work-request-{}", request.id),
396 kind: NotificationKind::WorkRequested,
397 title: format!("Work requested — {}", session.name),
398 body: request.description.clone(),
399 session_id: Some(session.id.clone()),
400 created_at: now_millis(),
401 }));
402 if let Some(orch) = session.orchestrator_id.clone() {
403 let msg = crate::lifecycle::reactions::format_work_request_reaction(
404 session, &request.description,
405 );
406 if let Err(e) = self.engine.send_to_session(&orch, &msg).await {
407 tracing::warn!("send work request to orchestrator {orch}: {e}");
408 }
409 }
410 }
411 let ids: Vec<String> = pending.iter().map(|r| r.id.clone()).collect();
415 if let Err(e) = hooks::mark_work_requests_delivered(sessions_dir, &session.id, &ids) {
416 tracing::warn!("mark work requests delivered for {}: {e}", session.id);
417 }
418 }
419
420 async fn poll_usage(&self) {
429 let Ok(sessions) = self.engine.store.list_sessions() else { return };
430 for mut session in sessions {
431 if matches!(session.status, SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted) {
432 continue;
433 }
434 let Some(workspace) = session.workspace_path.clone() else { continue };
435 let Some(snapshot) = usage::ingest_usage_for_workspace(&workspace) else { continue };
436
437 let cost_changed = (session.cost_usd - snapshot.cost_usd).abs() > 1e-9;
438 let context_changed = session.context_tokens != Some(snapshot.context_tokens);
439 if !cost_changed && !context_changed {
440 continue;
441 }
442
443 session.cost_usd = snapshot.cost_usd;
444 session.context_tokens = Some(snapshot.context_tokens);
445 if session.model.is_none() {
446 session.model = snapshot.model;
447 }
448 let _ = self.engine.store.upsert_session(&session);
449 self.engine.emit(Event::SessionUpdated(
450 session, SessionFields::COST | SessionFields::CONTEXT | SessionFields::MODEL,
451 ));
452 }
453 }
454
455 async fn poll_context_updates(&self) {
466 let Ok(sessions) = self.engine.store.list_sessions() else { return };
467 let mut changed = Vec::new();
468 {
469 let mut cache = self.context_cache.lock().unwrap();
470 for session in sessions {
471 let key = (session.cost_usd, session.context_used_pct, session.context_total_tokens);
472 if let Some(prev) = cache.insert(session.id.clone(), key) {
476 if prev != key {
477 changed.push(session);
478 }
479 }
480 }
481 }
482 for session in changed {
483 self.engine.emit(Event::SessionUpdated(
484 session, SessionFields::COST | SessionFields::CONTEXT,
485 ));
486 }
487 }
488
489 async fn poll_github(&self) {
492 let Some(gh) = &self.engine.github else { return };
493 let Ok(sessions) = self.engine.store.list_sessions() else { return };
494
495 for mut session in sessions {
496 if matches!(session.status, SessionStatus::Done) {
504 continue;
505 }
506 let Some(pr_number) = session.pr_number else { continue };
507
508 let mut found: Option<(String, u64, crate::github::PrStatus)> = None;
520 let mut last_err: Option<anyhow::Error> = None;
521 let mut attempted = false;
522 if !session.repo.is_empty() {
523 if let Some((owner, repo)) = split_repo(&session.repo) {
524 attempted = true;
525 match gh.get_pr_status(&owner, &repo, pr_number).await {
526 Ok(s) => found = Some((session.repo.clone(), pr_number, s)),
527 Err(e) => last_err = Some(e),
528 }
529 }
530 }
531 if found.is_none() {
532 if let Some(workspace) = session.workspace_path.clone() {
533 if let Some(branch) = crate::github::current_branch(&workspace) {
534 for repo_slug in crate::github::candidate_repos(&workspace) {
535 if repo_slug == session.repo {
536 continue; }
538 let Some((owner, repo)) = split_repo(&repo_slug) else { continue };
539 attempted = true;
540 let pr_ref = match gh.find_open_pr_for_branch(&owner, &repo, &branch).await {
541 Ok(Some(r)) => r,
542 Ok(None) => continue,
543 Err(e) => { last_err = Some(e); continue; }
544 };
545 match gh.get_pr_status(&owner, &repo, pr_ref.number).await {
546 Ok(s) => { found = Some((repo_slug, pr_ref.number, s)); break; }
547 Err(e) => { last_err = Some(e); continue; }
548 }
549 }
550 }
551 }
552 }
553 if !attempted {
554 continue; }
556 let Some((resolved_repo, pr_number, pr_status)) = found else {
557 if let Some(e) = last_err {
558 tracing::warn!("github pr status for {}: {e}", session.id);
559 }
560 self.notify_github_lookup_failed(&session);
561 continue;
562 };
563 self.clear_github_lookup_failed(&session.id);
564
565 let pr_id: PrId = pr_number as i64;
566
567 if resolved_repo != session.repo
579 || Some(pr_number) != session.pr_number
580 || session.pr_id != Some(pr_id)
581 {
582 if resolved_repo != session.repo || Some(pr_number) != session.pr_number {
583 tracing::info!(
584 "session {} repo/PR corrected {}#{:?} -> {resolved_repo}#{pr_number}",
585 session.id, session.repo, session.pr_number,
586 );
587 }
588 session.repo = resolved_repo;
589 session.pr_number = Some(pr_number);
590 session.pr_id = Some(pr_id);
591 let _ = self.engine.store.upsert_session(&session);
592 }
593 let Some((owner, repo)) = split_repo(&session.repo) else { continue };
594
595 if self.handle_merge_detection(&session, pr_number, pr_status.merged).await {
597 continue; }
602
603 {
605 let pr = PR {
606 id: pr_id,
607 number: pr_number,
608 title: pr_status.title.clone(),
609 url: format!("https://github.com/{owner}/{repo}/pull/{pr_number}"),
610 body: String::new(),
611 session_id: session.id.clone(),
612 };
613 let _ = self.engine.store.upsert_pr(&pr);
614 self.engine.emit(Event::PrOpened { session_id: session.id.clone(), pr });
615 }
616
617 let checks = match gh.get_ci_checks(&owner, &repo, &pr_status.head_sha).await {
619 Ok(c) => c,
620 Err(e) => { tracing::warn!("github ci checks: {e}"); vec![] }
621 };
622 let ci = summarize_checks(pr_id, &checks);
623 let _ = self.engine.store.upsert_ci_status(&ci);
624 self.engine.emit(Event::CiUpdated { pr_id, status: ci.clone() });
625
626 let (newly_failing, ci_reaction_already_sent) = {
628 let mut cache = self.enrichment_cache.lock().unwrap();
629 let state = cache.entry(session.id.clone()).or_default();
630
631 let newly_failing = state.prev_failing.is_none_or(|p| p == 0)
632 && ci.failing > 0;
633 state.prev_failing = Some(ci.failing);
634
635 let already_sent = state.ci_reaction_sent;
636 if newly_failing && !already_sent {
637 state.ci_reaction_sent = true;
638 }
639 if ci.failing == 0 {
640 state.ci_reaction_sent = false;
641 }
642 (newly_failing, already_sent)
643 };
644
645 if newly_failing && !ci_reaction_already_sent {
646 self.engine.emit(Event::Notification(Notification {
647 id: format!("ci-{}", session.id),
648 kind: NotificationKind::CiFailure,
649 title: format!("CI failing — {}", session.name),
650 body: format!("{}/{} checks failing", ci.failing, ci.total),
651 session_id: Some(session.id.clone()),
652 created_at: now_millis(),
653 }));
654 let failing_names: Vec<String> = checks.iter()
656 .filter(|c| c.conclusion.as_deref() == Some("failure")
657 || c.conclusion.as_deref() == Some("timed_out"))
658 .map(|c| c.name.clone())
659 .collect();
660 let msg = crate::lifecycle::reactions::format_ci_reaction(
661 &session, &ci, &failing_names
662 );
663 if let Err(e) = self.engine.send_to_session(&session.id, &msg).await {
664 tracing::warn!("send ci reaction to {}: {e}", session.id);
665 }
666 }
667
668 let threads = match gh.get_review_threads(&owner, &repo, pr_number).await {
670 Ok(t) => t,
671 Err(e) => { tracing::warn!("github review threads: {e}"); vec![] }
672 };
673
674 let has_changes_requested = threads.iter().any(|t| t.state == "CHANGES_REQUESTED");
675
676 let (has_new, review_reaction_already_sent, new_comments) = {
677 let mut cache = self.enrichment_cache.lock().unwrap();
678 let state = cache.entry(session.id.clone()).or_default();
679 let mut has_new = false;
680 let mut new_comments: Vec<Comment> = Vec::new();
681
682 for thread in &threads {
683 if thread.state == "CHANGES_REQUESTED"
684 && !state.seen_comment_ids.contains(&thread.id)
685 {
686 state.seen_comment_ids.insert(thread.id);
687 has_new = true;
688 let comment = Comment {
689 id: thread.id,
690 pr_id,
691 author: thread.author.clone(),
692 body: thread.body.clone(),
693 path: thread.path.clone(),
694 line: thread.line,
695 created_at: 0,
696 };
697 let _ = self.engine.store.upsert_comment(&comment);
698 self.engine.emit(Event::ReviewComment { pr_id, comment: comment.clone() });
699 new_comments.push(comment);
700 }
701 }
702
703 let already_sent = state.review_reaction_sent;
704 if has_new && !already_sent {
705 state.review_reaction_sent = true;
706 }
707 if !has_changes_requested {
709 state.review_reaction_sent = false;
710 }
711 (has_new, already_sent, new_comments)
712 };
713
714 let new_status = derive_session_status(&session.status, &pr_status, &ci, has_changes_requested);
716 let new_gate = compute_new_gate(
717 &session.status, &ci, has_changes_requested, pr_status.mergeable,
718 session.gate_status.as_ref(), now_millis(),
719 );
720 let mut updated = session.clone();
721 updated.status = new_status;
722 updated.gate_status = new_gate;
723 if updated.status != session.status || updated.gate_status != session.gate_status {
724 let _ = self.engine.store.upsert_session(&updated);
725 self.engine.emit(Event::SessionUpdated(
726 updated.clone(),
727 SessionFields::STATUS | SessionFields::PR_LINK | SessionFields::GATE,
728 ));
729 }
730
731 if has_new && !review_reaction_already_sent {
732 self.engine.emit(Event::Notification(Notification {
733 id: format!("review-{}", session.id),
734 kind: NotificationKind::PrNeedsAttention,
735 title: format!("Review comments — {}", session.name),
736 body: "Changes requested on your PR".to_string(),
737 session_id: Some(session.id.clone()),
738 created_at: now_millis(),
739 }));
740 if !new_comments.is_empty() {
741 let msg = crate::lifecycle::reactions::format_review_reaction(
742 &session, &new_comments
743 );
744 if let Err(e) = self.engine.send_to_session(&session.id, &msg).await {
745 tracing::warn!("send review reaction to {}: {e}", session.id);
746 }
747 }
748 }
749 }
750 }
751
752 fn notify_github_lookup_failed(&self, session: &crate::types::Session) {
756 let already_notified = {
757 let mut cache = self.enrichment_cache.lock().unwrap();
758 let state = cache.entry(session.id.clone()).or_default();
759 let already = state.github_lookup_failed_notified;
760 state.github_lookup_failed_notified = true;
761 already
762 };
763 if already_notified {
764 return;
765 }
766 self.engine.emit(Event::Notification(Notification {
767 id: format!("github-lookup-failed-{}", session.id),
768 kind: NotificationKind::GithubLookupFailed,
769 title: format!("GitHub lookup failing — {}", session.name),
770 body: "PR status/CI/review polling failed against every configured remote"
771 .to_string(),
772 session_id: Some(session.id.clone()),
773 created_at: now_millis(),
774 }));
775 }
776
777 fn clear_github_lookup_failed(&self, session_id: &str) {
780 let mut cache = self.enrichment_cache.lock().unwrap();
781 if let Some(state) = cache.get_mut(session_id) {
782 state.github_lookup_failed_notified = false;
783 }
784 }
785
786 async fn poll_pr_reconciliation(&self) {
797 let Some(gh) = &self.engine.github else { return };
798 let Ok(sessions) = self.engine.store.list_sessions() else { return };
799
800 for mut session in sessions {
801 if session.pr_number.is_some() {
802 continue;
803 }
804 if matches!(session.status, SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted) {
805 continue;
806 }
807 let Some(workspace) = session.workspace_path.clone() else { continue };
808 let Some(branch) = crate::github::current_branch(&workspace) else { continue };
809
810 for repo_slug in crate::github::candidate_repos(&workspace) {
811 let Some((owner, repo)) = split_repo(&repo_slug) else { continue };
812 match gh.find_open_pr_for_branch(&owner, &repo, &branch).await {
813 Ok(Some(pr_ref)) => {
814 session.pr_number = Some(pr_ref.number);
815 session.repo = repo_slug.clone();
816 session.status = SessionStatus::PrOpen;
817 let _ = self.engine.store.upsert_session(&session);
818 self.engine.emit(Event::SessionUpdated(
819 session.clone(), SessionFields::PR_LINK | SessionFields::STATUS,
820 ));
821 tracing::info!(
822 "session {} PR #{} detected via reconciliation ({repo_slug}, branch {branch})",
823 session.id, pr_ref.number,
824 );
825 break;
826 }
827 Ok(None) => continue,
828 Err(e) => {
829 tracing::warn!("reconciliation lookup {repo_slug} branch {branch}: {e}");
830 continue;
831 }
832 }
833 }
834 }
835 }
836 async fn handle_merge_detection(
848 &self,
849 session: &Session,
850 pr_number: u64,
851 pr_merged: bool,
852 ) -> bool {
853 if !pr_merged || matches!(session.status, SessionStatus::Done) {
854 return false;
855 }
856 self.engine.emit(Event::Notification(Notification {
857 id: format!("merged-{}", session.id),
858 kind: NotificationKind::WorkerDone,
859 title: format!("PR merged — {}", session.name),
860 body: format!("#{} merged successfully", pr_number),
861 session_id: Some(session.id.clone()),
862 created_at: now_millis(),
863 }));
864 if let Some(orch) = session.orchestrator_id.clone() {
867 let msg = crate::lifecycle::reactions::format_worker_done_reaction(session, pr_number);
868 if let Err(e) = self.engine.send_to_session(&orch, &msg).await {
869 tracing::warn!("send worker-done reaction to orchestrator {orch}: {e}");
870 }
871 }
872 if let Err(e) = self.engine.cleanup_session(&session.id).await {
873 tracing::warn!("cleanup_session {}: {e}", session.id);
874 }
875 {
877 let mut cache = self.enrichment_cache.lock().unwrap();
878 cache.remove(&session.id);
879 }
880 true
881 }
882
883 async fn sweep_retired_sessions(&self, retention: &SessionRetentionConfig) {
896 let Ok(sessions) = self.engine.store.list_sessions() else { return };
897 let Ok(orchestrators) = self.engine.store.list_orchestrators() else { return };
898 let orch_ids: std::collections::HashSet<&str> =
899 orchestrators.iter().map(|o| o.id.as_str()).collect();
900
901 let now = now_millis();
902 let retention_ms = retention.retention_millis();
903
904 for session in sessions {
905 if !matches!(session.status, SessionStatus::Done | SessionStatus::Terminated) {
906 continue;
907 }
908 if orch_ids.contains(session.id.as_str()) {
909 continue;
910 }
911 let expired = match session.terminal_at {
912 Some(t) => now.saturating_sub(t) >= retention_ms,
913 None => true,
914 };
915 if !expired {
916 continue;
917 }
918 if matches!(session.status, SessionStatus::Terminated) {
927 self.engine.emit(Event::Notification(Notification {
928 id: format!("retired-{}", session.id),
929 kind: NotificationKind::WorkerRetired,
930 title: format!("Worker retired — {}", session.name),
931 body: "Session cleaned up without a detected PR merge".to_string(),
932 session_id: Some(session.id.clone()),
933 created_at: now,
934 }));
935 if let Some(orch) = session.orchestrator_id.clone() {
936 let msg = crate::lifecycle::reactions::format_worker_retired_reaction(&session);
937 if let Err(e) = self.engine.send_to_session(&orch, &msg).await {
938 tracing::warn!("send worker-retired reaction to orchestrator {orch}: {e}");
939 }
940 }
941 }
942 if let Err(e) = self.engine.remove_session(&session.id).await {
947 tracing::warn!("purge retired session {}: {e}", session.id);
948 continue;
949 }
950 }
951 }
952}
953
954fn summarize_checks(pr_id: PrId, checks: &[CheckRun]) -> CIStatus {
957 let total = checks.len() as u32;
958 let failing = checks.iter().filter(|c| {
959 c.conclusion.as_deref() == Some("failure")
960 || c.conclusion.as_deref() == Some("timed_out")
961 }).count() as u32;
962 let passing = checks.iter().filter(|c| {
963 c.conclusion.as_deref() == Some("success")
964 }).count() as u32;
965 let pending = total - failing - passing;
966 CIStatus { pr_id, total, failing, passing, pending }
967}
968
969fn derive_session_status(
970 current: &SessionStatus,
971 pr_status: &crate::github::PrStatus,
972 ci: &CIStatus,
973 has_changes_requested: bool,
974) -> SessionStatus {
975 if matches!(current, SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted) {
977 return current.clone();
978 }
979 if pr_status.merged {
980 return SessionStatus::Done;
981 }
982 if ci.failing > 0 {
983 return SessionStatus::CiFailed;
984 }
985 if has_changes_requested {
986 return SessionStatus::ReviewPending;
987 }
988 if pr_status.mergeable == Some(true) && ci.failing == 0 && ci.pending == 0 {
989 return SessionStatus::Mergeable;
990 }
991 SessionStatus::PrOpen
992}
993
994fn derive_gate_status(
995 ci: &CIStatus,
996 has_changes_requested: bool,
997 mergeable: Option<bool>,
998 previous: Option<&GateStatus>,
999 now: i64,
1000) -> GateStatus {
1001 let ci_check = if ci.failing > 0 {
1002 GateCheck::Failing
1003 } else if ci.pending > 0 {
1004 GateCheck::Pending
1005 } else {
1006 GateCheck::Passing
1007 };
1008 let review_check = if has_changes_requested { GateCheck::Failing } else { GateCheck::Passing };
1009 let mergeable_check = match mergeable {
1010 Some(true) => GateCheck::Passing,
1011 Some(false) => GateCheck::Failing,
1012 None => GateCheck::Unknown,
1013 };
1014
1015 let unchanged = previous.is_some_and(|p| {
1016 p.ci == ci_check && p.review == review_check && p.mergeable == mergeable_check
1017 });
1018 let since = if unchanged { previous.unwrap().since } else { now };
1019
1020 GateStatus { ci: ci_check, review: review_check, mergeable: mergeable_check, since }
1021}
1022
1023fn compute_new_gate(
1038 current_status: &SessionStatus,
1039 ci: &CIStatus,
1040 has_changes_requested: bool,
1041 mergeable: Option<bool>,
1042 previous_gate: Option<&GateStatus>,
1043 now: i64,
1044) -> Option<GateStatus> {
1045 if matches!(current_status, SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted) {
1046 return previous_gate.cloned();
1047 }
1048 Some(derive_gate_status(ci, has_changes_requested, mergeable, previous_gate, now))
1049}
1050
1051#[cfg(test)]
1052mod tests {
1053 use super::*;
1054 use crate::types::SessionStatus;
1055
1056 #[test]
1057 fn summarize_checks_counts_failures() {
1058 let checks = vec![
1059 CheckRun { name: "lint".into(), status: "completed".into(), conclusion: Some("success".into()) },
1060 CheckRun { name: "test".into(), status: "completed".into(), conclusion: Some("failure".into()) },
1061 CheckRun { name: "build".into(), status: "in_progress".into(), conclusion: None },
1062 ];
1063 let ci = summarize_checks(1, &checks);
1064 assert_eq!(ci.total, 3);
1065 assert_eq!(ci.passing, 1);
1066 assert_eq!(ci.failing, 1);
1067 assert_eq!(ci.pending, 1);
1068 }
1069
1070 #[test]
1071 fn derive_status_merged_becomes_done() {
1072 let pr = crate::github::PrStatus {
1073 merged: true, state: "closed".into(), mergeable: None,
1074 title: "t".into(), number: 1, head_sha: String::new(),
1075 };
1076 let ci = CIStatus { pr_id: 1, total: 0, failing: 0, passing: 0, pending: 0 };
1077 let s = derive_session_status(&SessionStatus::PrOpen, &pr, &ci, false);
1078 assert!(matches!(s, SessionStatus::Done));
1079 }
1080
1081 #[test]
1082 fn derive_status_ci_failure_overrides_open() {
1083 let pr = crate::github::PrStatus {
1084 merged: false, state: "open".into(), mergeable: Some(true),
1085 title: "t".into(), number: 1, head_sha: String::new(),
1086 };
1087 let ci = CIStatus { pr_id: 1, total: 3, failing: 1, passing: 2, pending: 0 };
1088 let s = derive_session_status(&SessionStatus::PrOpen, &pr, &ci, false);
1089 assert!(matches!(s, SessionStatus::CiFailed));
1090 }
1091
1092 #[test]
1093 fn derive_status_all_green_becomes_mergeable() {
1094 let pr = crate::github::PrStatus {
1095 merged: false, state: "open".into(), mergeable: Some(true),
1096 title: "t".into(), number: 1, head_sha: String::new(),
1097 };
1098 let ci = CIStatus { pr_id: 1, total: 3, failing: 0, passing: 3, pending: 0 };
1099 let s = derive_session_status(&SessionStatus::PrOpen, &pr, &ci, false);
1100 assert!(matches!(s, SessionStatus::Mergeable));
1101 }
1102
1103 #[test]
1104 fn derive_gate_status_maps_raw_signals() {
1105 let ci = CIStatus { pr_id: 1, total: 3, passing: 1, failing: 1, pending: 1 };
1106 let gate = derive_gate_status(&ci, true, Some(false), None, 1_000);
1107 assert!(matches!(gate.ci, GateCheck::Failing));
1108 assert!(matches!(gate.review, GateCheck::Failing));
1109 assert!(matches!(gate.mergeable, GateCheck::Failing));
1110 assert_eq!(gate.since, 1_000, "first observation stamps `since` to `now`");
1111 }
1112
1113 #[test]
1114 fn derive_gate_status_maps_all_passing() {
1115 let ci = CIStatus { pr_id: 1, total: 2, passing: 2, failing: 0, pending: 0 };
1116 let gate = derive_gate_status(&ci, false, Some(true), None, 1_000);
1117 assert!(matches!(gate.ci, GateCheck::Passing));
1118 assert!(matches!(gate.review, GateCheck::Passing));
1119 assert!(matches!(gate.mergeable, GateCheck::Passing));
1120 }
1121
1122 #[test]
1123 fn derive_gate_status_maps_pending_ci_and_unknown_mergeable() {
1124 let ci = CIStatus { pr_id: 1, total: 2, passing: 0, failing: 0, pending: 2 };
1125 let gate = derive_gate_status(&ci, false, None, None, 1_000);
1126 assert!(matches!(gate.ci, GateCheck::Pending));
1127 assert!(matches!(gate.mergeable, GateCheck::Unknown));
1128 }
1129
1130 #[test]
1131 fn derive_gate_status_carries_since_forward_when_unchanged() {
1132 let ci = CIStatus { pr_id: 1, total: 1, passing: 1, failing: 0, pending: 0 };
1133 let previous = GateStatus {
1134 ci: GateCheck::Passing, review: GateCheck::Passing,
1135 mergeable: GateCheck::Passing, since: 500,
1136 };
1137 let gate = derive_gate_status(&ci, false, Some(true), Some(&previous), 9_999);
1138 assert_eq!(gate.since, 500, "unchanged combination keeps the original `since`");
1139 }
1140
1141 #[test]
1142 fn derive_gate_status_resets_since_when_combination_changes() {
1143 let ci = CIStatus { pr_id: 1, total: 1, passing: 0, failing: 1, pending: 0 };
1144 let previous = GateStatus {
1145 ci: GateCheck::Passing, review: GateCheck::Passing,
1146 mergeable: GateCheck::Passing, since: 500,
1147 };
1148 let gate = derive_gate_status(&ci, false, Some(true), Some(&previous), 9_999);
1149 assert!(matches!(gate.ci, GateCheck::Failing));
1150 assert_eq!(gate.since, 9_999, "a changed combination resets `since` to `now`");
1151 }
1152
1153 #[test]
1160 fn compute_new_gate_preserves_terminal_gate_even_when_live_signals_changed() {
1161 let previous = GateStatus {
1162 ci: GateCheck::Failing, review: GateCheck::Failing,
1163 mergeable: GateCheck::Failing, since: 111,
1164 };
1165 let ci = CIStatus { pr_id: 1, total: 3, passing: 3, failing: 0, pending: 0 };
1169
1170 for status in [SessionStatus::Terminated, SessionStatus::Interrupted, SessionStatus::Done] {
1171 let gate = compute_new_gate(&status, &ci, false, Some(true), Some(&previous), 9_999);
1172 assert_eq!(
1173 gate, Some(previous.clone()),
1174 "{status:?} session's gate must be carried forward unchanged, not recomputed",
1175 );
1176 }
1177 }
1178
1179 #[test]
1180 fn compute_new_gate_recomputes_for_non_terminal_status() {
1181 let previous = GateStatus {
1182 ci: GateCheck::Failing, review: GateCheck::Failing,
1183 mergeable: GateCheck::Failing, since: 111,
1184 };
1185 let ci = CIStatus { pr_id: 1, total: 3, passing: 3, failing: 0, pending: 0 };
1186
1187 let gate = compute_new_gate(&SessionStatus::PrOpen, &ci, false, Some(true), Some(&previous), 9_999);
1188 let gate = gate.expect("non-terminal status must produce a fresh gate");
1189 assert!(matches!(gate.ci, GateCheck::Passing), "must reflect the live all-green signals");
1190 assert_eq!(gate.since, 9_999, "changed combination resets `since` to `now`");
1191 }
1192
1193 #[test]
1194 fn derive_status_preserves_done() {
1195 let pr = crate::github::PrStatus {
1196 merged: false, state: "open".into(), mergeable: Some(true),
1197 title: "t".into(), number: 1, head_sha: String::new(),
1198 };
1199 let ci = CIStatus { pr_id: 1, total: 0, failing: 0, passing: 0, pending: 0 };
1200 let s = derive_session_status(&SessionStatus::Done, &pr, &ci, false);
1201 assert!(matches!(s, SessionStatus::Done));
1202 }
1203
1204 #[test]
1205 fn derive_status_preserves_terminated() {
1206 let pr = crate::github::PrStatus {
1207 merged: true, state: "closed".into(), mergeable: None, title: "t".into(), number: 1, head_sha: String::new(),
1209 };
1210 let ci = CIStatus { pr_id: 1, total: 0, failing: 0, passing: 0, pending: 0 };
1211 let s = derive_session_status(&SessionStatus::Terminated, &pr, &ci, false);
1212 assert!(matches!(s, SessionStatus::Terminated)); }
1214
1215 #[test]
1216 fn derive_status_changes_requested_becomes_review_pending() {
1217 let pr = crate::github::PrStatus {
1218 merged: false, state: "open".into(), mergeable: Some(true),
1219 title: "t".into(), number: 1, head_sha: String::new(),
1220 };
1221 let ci = CIStatus { pr_id: 1, total: 3, failing: 0, passing: 3, pending: 0 };
1222 let s = derive_session_status(&SessionStatus::PrOpen, &pr, &ci, true);
1223 assert!(matches!(s, SessionStatus::ReviewPending));
1224 }
1225
1226 fn test_session(id: &str, workspace: &str) -> crate::types::Session {
1227 crate::types::Session {
1228 id: id.into(), orchestrator_id: None, name: id.into(),
1229 repo: String::new(), status: SessionStatus::Working,
1230 agent_type: "claude-code".into(), cost_usd: 0.0, started_at: 0,
1231 pr_number: None, pr_id: None,
1232 workspace_path: Some(workspace.into()), pid: None,
1233 model: None, context_tokens: None, catalogue_path: None,
1234 context_used_pct: None, context_total_tokens: None, context_window_size: None,
1235 claude_session_id: None,
1236 summary: None,
1237 terminal_at: None, gate_status: None,
1238 }
1239 }
1240
1241 #[tokio::test]
1242 async fn poll_pids_leaves_interrupted_sessions_alone() {
1243 use crate::store::Store;
1244
1245 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1246 let mut s = test_session("interrupted-1", "/ws");
1247 s.status = SessionStatus::Interrupted;
1248 s.pid = Some(999_999); store.upsert_session(&s).unwrap();
1250 let engine = Engine::new(store.clone());
1251 let poller = Poller::new(engine);
1252
1253 poller.poll_pids().await;
1254
1255 let after = store.get_session("interrupted-1").unwrap().unwrap();
1256 assert!(
1257 matches!(after.status, SessionStatus::Interrupted),
1258 "poll_pids must not re-terminate an Interrupted session just because its stale pid is dead",
1259 );
1260 }
1261
1262 #[allow(clippy::await_holding_lock)]
1275 #[tokio::test]
1276 async fn poll_usage_ingests_transcript_into_store_and_emits_update() {
1277 use crate::{lifecycle::usage::{claude_project_slug, ENV_TEST_GUARD}, store::Store};
1278 use std::io::Write;
1279
1280 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1281 let projects_dir = tempfile::tempdir().unwrap();
1282 let workspace = "/tmp/poller-usage-probe-workspace";
1283 let project_dir = projects_dir.path().join(claude_project_slug(workspace));
1284 std::fs::create_dir_all(&project_dir).unwrap();
1285 let mut f = std::fs::File::create(project_dir.join("s.jsonl")).unwrap();
1286 writeln!(
1287 f,
1288 r#"{{"type":"assistant","timestamp":"2026-07-05T13:00:00.000Z","message":{{"model":"claude-fable-5","usage":{{"input_tokens":2,"output_tokens":300,"cache_creation_input_tokens":500,"cache_read_input_tokens":45000}}}}}}"#
1289 ).unwrap();
1290 drop(f);
1291
1292 let prior = std::env::var("NINOX_CLAUDE_PROJECTS_DIR").ok();
1293 std::env::set_var("NINOX_CLAUDE_PROJECTS_DIR", projects_dir.path());
1294
1295 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1296 store.upsert_session(&test_session("s1", workspace)).unwrap();
1297 let engine = Engine::new(store.clone());
1298 let mut rx = engine.subscribe();
1299 let poller = Poller::new(engine);
1300
1301 poller.poll_usage().await;
1302
1303 match prior {
1304 Some(v) => std::env::set_var("NINOX_CLAUDE_PROJECTS_DIR", v),
1305 None => std::env::remove_var("NINOX_CLAUDE_PROJECTS_DIR"),
1306 }
1307
1308 let updated = store.get_session("s1").unwrap().unwrap();
1309 assert!(updated.cost_usd > 0.0, "cost_usd should be ingested, not 0.0000");
1310 assert_eq!(updated.context_tokens, Some(2 + 500 + 45000));
1311 assert_eq!(updated.model.as_deref(), Some("claude-fable-5"));
1312
1313 let evt = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
1314 .await
1315 .expect("SessionUpdated should be emitted")
1316 .unwrap();
1317 assert!(matches!(evt, Event::SessionUpdated(s, _fields) if s.id == "s1" && s.cost_usd > 0.0));
1318 }
1319
1320 #[tokio::test]
1326 async fn poll_context_updates_emits_only_for_changed_sessions() {
1327 use crate::store::Store;
1328
1329 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1330 let mut s1 = test_session("s1", "/ws1");
1331 let s2 = test_session("s2", "/ws2");
1332 store.upsert_session(&s1).unwrap();
1333 store.upsert_session(&s2).unwrap();
1334 let engine = Engine::new(store.clone());
1335 let mut rx = engine.subscribe();
1336 let poller = Poller::new(engine);
1337
1338 poller.poll_context_updates().await;
1342 let baseline_events = drain_events(&mut rx);
1343 assert!(baseline_events.is_empty(), "no prior cached state means no change to report");
1344
1345 s1.context_used_pct = Some(42.0);
1347 s1.cost_usd = 3.5;
1348 store.upsert_session(&s1).unwrap();
1349
1350 poller.poll_context_updates().await;
1351 let events = drain_events(&mut rx);
1352 assert_eq!(events.len(), 1, "only the changed session should emit");
1353 assert!(matches!(
1354 &events[0],
1355 Event::SessionUpdated(s, _fields) if s.id == "s1" && s.context_used_pct == Some(42.0) && s.cost_usd == 3.5
1356 ));
1357
1358 poller.poll_context_updates().await;
1360 assert!(drain_events(&mut rx).is_empty());
1361 }
1362
1363 fn drain_events(rx: &mut tokio::sync::broadcast::Receiver<Event>) -> Vec<Event> {
1365 let mut events = Vec::new();
1366 while let Ok(e) = rx.try_recv() {
1367 events.push(e);
1368 }
1369 events
1370 }
1371
1372 #[tokio::test]
1376 async fn metadata_sync_adopts_first_pr_and_flags_every_extra_once() {
1377 use crate::store::Store;
1378
1379 let sessions_dir = tempfile::tempdir().unwrap();
1380 let meta = serde_json::json!({
1381 "agentReportedPrNumber": "44",
1382 "agentReportedPrUrl": "https://github.com/org/repo/pull/44",
1383 "agentReportedPrs": [
1384 {"number": "42", "url": "https://github.com/org/repo/pull/42"},
1385 {"number": "43", "url": "https://github.com/org/repo/pull/43"},
1386 {"number": "44", "url": "https://github.com/org/repo/pull/44"},
1387 ],
1388 });
1389 std::fs::write(
1390 sessions_dir.path().join("s1.json"),
1391 serde_json::to_string(&meta).unwrap(),
1392 ).unwrap();
1393
1394 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1395 store.upsert_session(&test_session("s1", "/ws")).unwrap();
1396 let engine = Engine::new(store.clone());
1397 let mut rx = engine.subscribe();
1398 let poller = Poller::new(engine);
1399
1400 poller.sync_sessions_metadata(sessions_dir.path()).await;
1401
1402 let session = store.get_session("s1").unwrap().unwrap();
1403 assert_eq!(session.pr_number, Some(42), "first PR is the canonical one");
1404 assert!(matches!(session.status, SessionStatus::PrOpen));
1405 assert!(store.get_pr(43).unwrap().is_some(), "extra PR #43 recorded");
1406 assert!(store.get_pr(44).unwrap().is_some(), "extra PR #44 recorded");
1407 assert_eq!(
1408 store.get_pr(43).unwrap().unwrap().url,
1409 "https://github.com/org/repo/pull/43",
1410 );
1411
1412 let events = drain_events(&mut rx);
1413 let extra_notifs: Vec<_> = events.iter().filter(|e| matches!(
1414 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::ExtraPr
1415 )).collect();
1416 assert_eq!(extra_notifs.len(), 2, "one ExtraPr notification per extra PR");
1417
1418 let mut extra_pr_numbers: Vec<u64> = events.iter().filter_map(|e| match e {
1422 Event::ExtraPrDetected(pr) => Some(pr.number),
1423 _ => None,
1424 }).collect();
1425 extra_pr_numbers.sort();
1426 assert_eq!(
1427 extra_pr_numbers, vec![43, 44],
1428 "ExtraPrDetected must fire for every extra PR, not just the notification",
1429 );
1430 let pr_43 = events.iter().find_map(|e| match e {
1431 Event::ExtraPrDetected(pr) if pr.number == 43 => Some(pr),
1432 _ => None,
1433 }).expect("ExtraPrDetected for #43");
1434 assert_eq!(pr_43.session_id, "s1", "the ledger entry must point back at the owning session");
1435
1436 poller.sync_sessions_metadata(sessions_dir.path()).await;
1438 let events = drain_events(&mut rx);
1439 assert!(
1440 !events.iter().any(|e| matches!(e, Event::ExtraPrDetected(_))),
1441 "an extra PR already recorded in the store must not be re-detected on every tick",
1442 );
1443 assert!(
1444 !events.iter().any(|e| matches!(e, Event::Notification(_))),
1445 "extra PRs must not be re-notified on every tick",
1446 );
1447 }
1448
1449 #[tokio::test]
1452 async fn metadata_sync_single_pr_has_no_extra_notifications() {
1453 use crate::store::Store;
1454
1455 let sessions_dir = tempfile::tempdir().unwrap();
1456 let meta = serde_json::json!({
1457 "agentReportedPrNumber": "5",
1458 "agentReportedPrUrl": "https://github.com/org/repo/pull/5",
1459 });
1460 std::fs::write(
1461 sessions_dir.path().join("s1.json"),
1462 serde_json::to_string(&meta).unwrap(),
1463 ).unwrap();
1464
1465 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1466 store.upsert_session(&test_session("s1", "/ws")).unwrap();
1467 let engine = Engine::new(store.clone());
1468 let mut rx = engine.subscribe();
1469 let poller = Poller::new(engine);
1470
1471 poller.sync_sessions_metadata(sessions_dir.path()).await;
1472
1473 let session = store.get_session("s1").unwrap().unwrap();
1474 assert_eq!(session.pr_number, Some(5));
1475 assert!(matches!(session.status, SessionStatus::PrOpen));
1476 let events = drain_events(&mut rx);
1477 assert!(!events.iter().any(|e| matches!(e, Event::Notification(_))));
1478 }
1479
1480 #[tokio::test]
1483 async fn metadata_sync_delivers_work_requests_exactly_once() {
1484 use crate::store::Store;
1485
1486 let sessions_dir = tempfile::tempdir().unwrap();
1487 hooks::append_work_request(sessions_dir.path(), "s1", "Migrate the config loader").unwrap();
1488
1489 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1490 store.upsert_session(&test_session("s1", "/ws")).unwrap();
1491 let engine = Engine::new(store.clone());
1492 let mut rx = engine.subscribe();
1493 let poller = Poller::new(engine);
1494
1495 poller.sync_sessions_metadata(sessions_dir.path()).await;
1496
1497 let events = drain_events(&mut rx);
1498 let notif = events.iter().find_map(|e| match e {
1499 Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkRequested => Some(n),
1500 _ => None,
1501 }).expect("WorkRequested notification emitted");
1502 assert!(notif.body.contains("Migrate the config loader"));
1503 assert_eq!(notif.session_id.as_deref(), Some("s1"));
1504
1505 assert!(
1506 hooks::read_pending_work_requests(sessions_dir.path(), "s1").unwrap().is_empty(),
1507 "delivered requests must leave the pending set",
1508 );
1509
1510 poller.sync_sessions_metadata(sessions_dir.path()).await;
1511 let events = drain_events(&mut rx);
1512 assert!(
1513 !events.iter().any(|e| matches!(e, Event::Notification(_))),
1514 "delivered work requests must not fire again",
1515 );
1516 }
1517
1518 #[tokio::test]
1521 async fn metadata_sync_delivers_work_requests_from_terminated_sessions() {
1522 use crate::store::Store;
1523
1524 let sessions_dir = tempfile::tempdir().unwrap();
1525 hooks::append_work_request(sessions_dir.path(), "s1", "Follow-up refactor").unwrap();
1526
1527 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1528 let mut session = test_session("s1", "/ws");
1529 session.status = SessionStatus::Terminated;
1530 store.upsert_session(&session).unwrap();
1531 let engine = Engine::new(store.clone());
1532 let mut rx = engine.subscribe();
1533 let poller = Poller::new(engine);
1534
1535 poller.sync_sessions_metadata(sessions_dir.path()).await;
1536
1537 let events = drain_events(&mut rx);
1538 assert!(
1539 events.iter().any(|e| matches!(
1540 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkRequested
1541 )),
1542 "work requests outlive their session",
1543 );
1544 }
1545
1546 #[tokio::test]
1550 async fn extra_pr_ledger_row_backfills_after_notification_without_renotifying() {
1551 use crate::store::Store;
1552
1553 let sessions_dir = tempfile::tempdir().unwrap();
1554 let meta = serde_json::json!({
1555 "agentReportedPrs": [
1556 {"number": "7", "url": "https://github.com/org/repo/pull/7"},
1557 {"number": "9", "url": "https://github.com/org/repo/pull/9"},
1558 ],
1559 });
1560 std::fs::write(
1561 sessions_dir.path().join("s1.json"),
1562 serde_json::to_string(&meta).unwrap(),
1563 ).unwrap();
1564 hooks::mark_extra_prs_notified(sessions_dir.path(), "s1", &[9]).unwrap();
1566
1567 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1568 store.upsert_session(&test_session("s1", "/ws")).unwrap();
1569 let engine = Engine::new(store.clone());
1570 let mut rx = engine.subscribe();
1571 let poller = Poller::new(engine);
1572
1573 poller.sync_sessions_metadata(sessions_dir.path()).await;
1574
1575 assert!(
1576 store.get_pr(9).unwrap().is_some(),
1577 "already-notified extra PR must still get its ledger row backfilled",
1578 );
1579 let events = drain_events(&mut rx);
1580 assert!(
1581 !events.iter().any(|e| matches!(
1582 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::ExtraPr
1583 )),
1584 "backfilling the row must not re-notify",
1585 );
1586 }
1587
1588 #[tokio::test]
1592 async fn extra_pr_detection_survives_cross_repo_pr_number_collision() {
1593 use crate::store::Store;
1594
1595 let sessions_dir = tempfile::tempdir().unwrap();
1596 let meta = serde_json::json!({
1597 "agentReportedPrs": [
1598 {"number": "7", "url": "https://github.com/org/repo-a/pull/7"},
1599 {"number": "9", "url": "https://github.com/org/repo-a/pull/9"},
1600 ],
1601 });
1602 std::fs::write(
1603 sessions_dir.path().join("s1.json"),
1604 serde_json::to_string(&meta).unwrap(),
1605 ).unwrap();
1606
1607 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1608 store.upsert_session(&test_session("s1", "/ws")).unwrap();
1609 let other = PR {
1611 id: 9, number: 9, title: "other repo's PR".into(),
1612 url: "https://github.com/org/repo-b/pull/9".into(),
1613 body: String::new(), session_id: "other".into(),
1614 };
1615 store.upsert_pr(&other).unwrap();
1616
1617 let engine = Engine::new(store.clone());
1618 let mut rx = engine.subscribe();
1619 let poller = Poller::new(engine);
1620
1621 poller.sync_sessions_metadata(sessions_dir.path()).await;
1622
1623 let events = drain_events(&mut rx);
1624 assert!(
1625 events.iter().any(|e| matches!(
1626 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::ExtraPr
1627 )),
1628 "the collision must not suppress the extra-PR alert",
1629 );
1630 let row = store.get_pr(9).unwrap().unwrap();
1631 assert_eq!(row.session_id, "other", "the other repo's row must not be stolen");
1632 assert_eq!(row.url, "https://github.com/org/repo-b/pull/9");
1633 assert!(
1634 !events.iter().any(|e| matches!(e, Event::ExtraPrDetected(pr) if pr.number == 9)),
1635 "must not emit ExtraPrDetected for a row it didn't (and mustn't) write — that \
1636 would let s1 clobber the ledger UI's view of another session's PR",
1637 );
1638
1639 poller.sync_sessions_metadata(sessions_dir.path()).await;
1640 let events = drain_events(&mut rx);
1641 assert!(
1642 !events.iter().any(|e| matches!(e, Event::Notification(_))),
1643 "dedup must hold across ticks even without a prs row of our own",
1644 );
1645 }
1646
1647 #[tokio::test]
1648 async fn poll_usage_leaves_sessions_without_workspace_or_usage_untouched() {
1649 use crate::store::Store;
1650
1651 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1652 let mut no_ws = test_session("no-ws", "/does/not/matter");
1653 no_ws.workspace_path = None;
1654 store.upsert_session(&no_ws).unwrap();
1655 let engine = Engine::new(store.clone());
1656 let poller = Poller::new(engine);
1657
1658 poller.poll_usage().await;
1659
1660 let unchanged = store.get_session("no-ws").unwrap().unwrap();
1661 assert_eq!(unchanged.cost_usd, 0.0);
1662 assert_eq!(unchanged.context_tokens, None);
1663 }
1664
1665 #[derive(Default)]
1670 struct FakeGithub {
1671 pr_status_ok: std::sync::Mutex<HashMap<(String, String, u64), crate::github::PrStatus>>,
1675 branch_matches: std::sync::Mutex<HashMap<(String, String, String), crate::github::PrRef>>,
1676 calls: std::sync::Mutex<Vec<(String, String, u64)>>,
1678 }
1679
1680 #[async_trait::async_trait]
1681 impl crate::github::GithubApi for FakeGithub {
1682 async fn get_pr_status(&self, owner: &str, repo: &str, pr_number: u64) -> anyhow::Result<crate::github::PrStatus> {
1683 self.calls.lock().unwrap().push((owner.to_string(), repo.to_string(), pr_number));
1684 self.pr_status_ok.lock().unwrap()
1685 .get(&(owner.to_string(), repo.to_string(), pr_number))
1686 .cloned()
1687 .ok_or_else(|| anyhow::anyhow!("404 for {owner}/{repo}#{pr_number}"))
1688 }
1689 async fn get_ci_checks(&self, _owner: &str, _repo: &str, _head_sha: &str) -> anyhow::Result<Vec<CheckRun>> {
1690 Ok(vec![])
1691 }
1692 async fn get_review_threads(&self, _owner: &str, _repo: &str, _pr_number: u64) -> anyhow::Result<Vec<crate::github::ReviewThread>> {
1693 Ok(vec![])
1694 }
1695 async fn find_open_pr_for_branch(&self, owner: &str, repo: &str, branch: &str) -> anyhow::Result<Option<crate::github::PrRef>> {
1696 Ok(self.branch_matches.lock().unwrap()
1697 .get(&(owner.to_string(), repo.to_string(), branch.to_string()))
1698 .cloned())
1699 }
1700 }
1701
1702 fn init_git_repo(branch: &str, remotes: &[(&str, &str)]) -> tempfile::TempDir {
1703 let dir = tempfile::tempdir().unwrap();
1704 let workspace = dir.path().to_string_lossy().to_string();
1705 let run = |args: &[&str]| {
1706 let status = std::process::Command::new("git")
1707 .args(["-C", &workspace]).args(args).status().unwrap();
1708 assert!(status.success(), "git {args:?} failed");
1709 };
1710 run(&["init", "-q"]);
1711 run(&["config", "user.email", "test@example.com"]);
1712 run(&["config", "user.name", "Test"]);
1713 run(&["commit", "--allow-empty", "-q", "-m", "init"]);
1714 run(&["checkout", "-q", "-b", branch]);
1715 for (name, url) in remotes {
1716 run(&["remote", "add", name, url]);
1717 }
1718 dir
1719 }
1720
1721 fn github_engine(store: std::sync::Arc<crate::store::Store>, gh: std::sync::Arc<FakeGithub>) -> std::sync::Arc<Engine> {
1722 Engine::new_with_github_api(store, gh as std::sync::Arc<dyn crate::github::GithubApi>)
1723 }
1724
1725 #[tokio::test]
1730 async fn poll_pr_reconciliation_adopts_pr_found_without_wrapper_metadata() {
1731 use crate::store::Store;
1732
1733 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
1734 let workspace = repo_dir.path().to_string_lossy().to_string();
1735
1736 let fake = std::sync::Arc::new(FakeGithub::default());
1737 fake.branch_matches.lock().unwrap().insert(
1738 ("Owner".to_string(), "repo".to_string(), "worker-branch".to_string()),
1739 crate::github::PrRef { number: 77, url: "https://github.com/Owner/repo/pull/77".into() },
1740 );
1741
1742 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1743 store.upsert_session(&test_session("s1", &workspace)).unwrap();
1744 let engine = github_engine(store.clone(), fake);
1745 let poller = Poller::new(engine);
1746
1747 poller.poll_pr_reconciliation().await;
1748
1749 let session = store.get_session("s1").unwrap().unwrap();
1750 assert_eq!(session.pr_number, Some(77), "no wrapper metadata was ever written — reconciliation must still find it");
1751 assert!(matches!(session.status, SessionStatus::PrOpen));
1752 assert_eq!(session.repo, "Owner/repo");
1753 }
1754
1755 #[tokio::test]
1756 async fn poll_pr_reconciliation_leaves_sessions_with_no_matching_pr_untouched() {
1757 use crate::store::Store;
1758
1759 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
1760 let workspace = repo_dir.path().to_string_lossy().to_string();
1761
1762 let fake = std::sync::Arc::new(FakeGithub::default()); let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1765 store.upsert_session(&test_session("s1", &workspace)).unwrap();
1766 let engine = github_engine(store.clone(), fake);
1767 let poller = Poller::new(engine);
1768
1769 poller.poll_pr_reconciliation().await;
1770
1771 let session = store.get_session("s1").unwrap().unwrap();
1772 assert_eq!(session.pr_number, None);
1773 }
1774
1775 #[tokio::test]
1785 async fn poll_github_falls_back_to_other_remote_when_recorded_repo_404s() {
1786 use crate::store::Store;
1787
1788 let repo_dir = init_git_repo("worker-branch", &[
1789 ("origin", "https://github.com/OwnerA/repoA.git"),
1790 ("mirror", "https://github.com/OwnerB/repoB.git"),
1791 ]);
1792 let workspace = repo_dir.path().to_string_lossy().to_string();
1793
1794 let fake = std::sync::Arc::new(FakeGithub::default());
1795 fake.branch_matches.lock().unwrap().insert(
1796 ("OwnerB".to_string(), "repoB".to_string(), "worker-branch".to_string()),
1797 crate::github::PrRef { number: 99, url: "https://github.com/OwnerB/repoB/pull/99".into() },
1798 );
1799 fake.pr_status_ok.lock().unwrap().insert(
1800 ("OwnerB".to_string(), "repoB".to_string(), 99),
1801 crate::github::PrStatus {
1802 merged: false, state: "open".into(), mergeable: Some(true),
1803 title: "t".into(), number: 99, head_sha: "abc".into(),
1804 },
1805 );
1806 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1809 let mut session = test_session("s1", &workspace);
1810 session.repo = "OwnerA/repoA".into();
1811 session.pr_number = Some(50);
1812 store.upsert_session(&session).unwrap();
1813
1814 let engine = github_engine(store.clone(), fake.clone());
1815 let poller = Poller::new(engine);
1816
1817 poller.poll_github().await;
1818
1819 let updated = store.get_session("s1").unwrap().unwrap();
1820 assert_eq!(updated.repo, "OwnerB/repoB", "session.repo must self-heal to the remote that actually has the PR");
1821 assert_eq!(updated.pr_number, Some(99), "must adopt the mirror's own PR number, not reuse the tracked repo's number");
1822
1823 let calls = fake.calls.lock().unwrap().clone();
1824 assert_eq!(
1825 calls,
1826 vec![("OwnerA".to_string(), "repoA".to_string(), 50), ("OwnerB".to_string(), "repoB".to_string(), 99)],
1827 "the repo on record must be tried first (by its own number), the mirror only as a branch-matched fallback",
1828 );
1829 }
1830
1831 #[tokio::test]
1840 async fn poll_github_does_not_adopt_an_unrelated_pr_that_shares_the_tracked_number() {
1841 use crate::store::Store;
1842
1843 let repo_dir = init_git_repo("worker-branch", &[
1844 ("origin", "https://github.com/OwnerA/repoA.git"),
1845 ("mirror", "https://github.com/OwnerB/repoB.git"),
1846 ]);
1847 let workspace = repo_dir.path().to_string_lossy().to_string();
1848
1849 let fake = std::sync::Arc::new(FakeGithub::default());
1850 fake.pr_status_ok.lock().unwrap().insert(
1853 ("OwnerB".to_string(), "repoB".to_string(), 50),
1854 crate::github::PrStatus {
1855 merged: false, state: "open".into(), mergeable: Some(true),
1856 title: "someone else's unrelated PR".into(), number: 50, head_sha: "zzz".into(),
1857 },
1858 );
1859 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1862 let mut session = test_session("s1", &workspace);
1863 session.repo = "OwnerA/repoA".into();
1864 session.pr_number = Some(50);
1865 store.upsert_session(&session).unwrap();
1866
1867 let engine = github_engine(store.clone(), fake.clone());
1868 let poller = Poller::new(engine);
1869
1870 poller.poll_github().await;
1871
1872 let updated = store.get_session("s1").unwrap().unwrap();
1873 assert_eq!(updated.repo, "OwnerA/repoA", "must not adopt the mirror just because it happens to have a same-numbered PR");
1874 assert_eq!(updated.pr_number, Some(50));
1875
1876 let calls = fake.calls.lock().unwrap().clone();
1877 assert!(
1878 !calls.contains(&("OwnerB".to_string(), "repoB".to_string(), 50)),
1879 "must never retry the tracked number against another repo's get_pr_status: {calls:?}",
1880 );
1881 }
1882
1883 #[tokio::test]
1895 async fn poll_github_persists_pr_id_so_later_polls_dont_regress_it() {
1896 use crate::store::Store;
1897
1898 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
1899 let workspace = repo_dir.path().to_string_lossy().to_string();
1900
1901 let fake = std::sync::Arc::new(FakeGithub::default());
1902 fake.pr_status_ok.lock().unwrap().insert(
1903 ("Owner".to_string(), "repo".to_string(), 50),
1904 crate::github::PrStatus {
1905 merged: false, state: "open".into(), mergeable: Some(true),
1906 title: "t".into(), number: 50, head_sha: "abc".into(),
1907 },
1908 );
1909
1910 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1911 let mut session = test_session("s1", &workspace);
1912 session.repo = "Owner/repo".into();
1913 session.pr_number = Some(50);
1914 store.upsert_session(&session).unwrap();
1915
1916 let engine = github_engine(store.clone(), fake);
1917 let poller = Poller::new(engine);
1918
1919 poller.poll_github().await;
1920
1921 let updated = store.get_session("s1").unwrap().unwrap();
1922 assert_eq!(
1923 updated.pr_id, Some(50),
1924 "pr_id discovered by poll_github must be persisted to the store, or a later \
1925 read-modify-write by any other poller will re-broadcast pr_id: None and flicker the UI",
1926 );
1927 }
1928
1929 #[tokio::test]
1937 async fn poll_github_does_not_recompute_gate_for_terminated_session_with_unresolved_pr() {
1938 use crate::store::Store;
1939
1940 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
1941 let workspace = repo_dir.path().to_string_lossy().to_string();
1942
1943 let fake = std::sync::Arc::new(FakeGithub::default());
1944 fake.pr_status_ok.lock().unwrap().insert(
1945 ("Owner".to_string(), "repo".to_string(), 50),
1946 crate::github::PrStatus {
1947 merged: false, state: "open".into(), mergeable: Some(true),
1953 title: "t".into(), number: 50, head_sha: "abc".into(),
1954 },
1955 );
1956
1957 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1958 let mut session = test_session("s1", &workspace);
1959 session.repo = "Owner/repo".into();
1960 session.pr_number = Some(50);
1961 session.status = SessionStatus::Terminated;
1962 let stale_gate = GateStatus {
1963 ci: GateCheck::Failing, review: GateCheck::Failing,
1964 mergeable: GateCheck::Failing, since: 111,
1965 };
1966 session.gate_status = Some(stale_gate.clone());
1967 store.upsert_session(&session).unwrap();
1968
1969 let engine = github_engine(store.clone(), fake);
1970 let poller = Poller::new(engine);
1971
1972 poller.poll_github().await;
1973
1974 let updated = store.get_session("s1").unwrap().unwrap();
1975 assert!(
1976 matches!(updated.status, SessionStatus::Terminated),
1977 "status must stay Terminated (derive_session_status's own terminal guard)",
1978 );
1979 assert_eq!(
1980 updated.gate_status, Some(stale_gate),
1981 "gate_status must be carried forward unchanged for a terminal session, \
1982 even though live CI/mergeable data disagrees with it",
1983 );
1984 }
1985
1986 #[tokio::test]
1999 async fn poll_github_persists_pr_id_when_the_self_healed_pr_is_already_merged() {
2000 use crate::store::Store;
2001
2002 let repo_dir = init_git_repo("worker-branch", &[
2003 ("origin", "https://github.com/OwnerA/repoA.git"),
2004 ("mirror", "https://github.com/OwnerB/repoB.git"),
2005 ]);
2006 let workspace = repo_dir.path().to_string_lossy().to_string();
2007
2008 let fake = std::sync::Arc::new(FakeGithub::default());
2009 fake.branch_matches.lock().unwrap().insert(
2010 ("OwnerB".to_string(), "repoB".to_string(), "worker-branch".to_string()),
2011 crate::github::PrRef { number: 99, url: "https://github.com/OwnerB/repoB/pull/99".into() },
2012 );
2013 fake.pr_status_ok.lock().unwrap().insert(
2014 ("OwnerB".to_string(), "repoB".to_string(), 99),
2015 crate::github::PrStatus {
2016 merged: true, state: "closed".into(), mergeable: None,
2017 title: "t".into(), number: 99, head_sha: "abc".into(),
2018 },
2019 );
2020 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2024 let mut session = test_session("s1", &workspace);
2025 session.repo = "OwnerA/repoA".into();
2026 session.pr_number = Some(50);
2027 store.upsert_session(&session).unwrap();
2028
2029 let engine = github_engine(store.clone(), fake);
2030 let poller = Poller::new(engine);
2031
2032 poller.poll_github().await;
2033
2034 let updated = store.get_session("s1").unwrap().unwrap();
2035 assert_eq!(updated.repo, "OwnerB/repoB", "self-heal must still land");
2036 assert_eq!(updated.pr_number, Some(99), "self-heal must still land");
2037 assert_eq!(
2038 updated.pr_id, Some(99),
2039 "pr_id must be persisted even though the self-healed PR is already merged and \
2040 merge detection short-circuits the rest of this loop iteration",
2041 );
2042 assert!(matches!(updated.status, SessionStatus::Done), "merged PR still transitions to Done");
2043 }
2044
2045 #[tokio::test]
2049 async fn poll_github_notifies_once_when_every_remote_fails_and_rearms_after_recovery() {
2050 use crate::store::Store;
2051
2052 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/OwnerA/repoA.git")]);
2053 let workspace = repo_dir.path().to_string_lossy().to_string();
2054
2055 let fake = std::sync::Arc::new(FakeGithub::default()); let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2058 let mut session = test_session("s1", &workspace);
2059 session.repo = "OwnerA/repoA".into();
2060 session.pr_number = Some(50);
2061 store.upsert_session(&session).unwrap();
2062
2063 let engine = github_engine(store.clone(), fake.clone());
2064 let mut rx = engine.subscribe();
2065 let poller = Poller::new(engine);
2066
2067 poller.poll_github().await;
2068 let events = drain_events(&mut rx);
2069 let failures = |evs: &[Event]| evs.iter().filter(|e| matches!(
2070 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::GithubLookupFailed
2071 )).count();
2072 assert_eq!(failures(&events), 1, "first failure must notify");
2073
2074 poller.poll_github().await;
2075 let events = drain_events(&mut rx);
2076 assert_eq!(failures(&events), 0, "repeated failure must not re-notify");
2077
2078 fake.pr_status_ok.lock().unwrap().insert(
2080 ("OwnerA".to_string(), "repoA".to_string(), 50),
2081 crate::github::PrStatus {
2082 merged: false, state: "open".into(), mergeable: Some(true),
2083 title: "t".into(), number: 50, head_sha: "abc".into(),
2084 },
2085 );
2086 poller.poll_github().await;
2087
2088 fake.pr_status_ok.lock().unwrap().clear();
2090 poller.poll_github().await;
2091 let events = drain_events(&mut rx);
2092 assert_eq!(failures(&events), 1, "must notify again after recovering and failing anew");
2093 }
2094
2095 struct FakeUpdateSource(std::sync::Mutex<Option<semver::Version>>);
2101
2102 #[async_trait::async_trait]
2103 impl UpdateSource for FakeUpdateSource {
2104 async fn latest_version(&self, _package: &str) -> anyhow::Result<Option<semver::Version>> {
2105 Ok(self.0.lock().unwrap().clone())
2106 }
2107 }
2108
2109 fn bare_engine() -> Arc<Engine> {
2110 use crate::store::Store;
2111 let store = Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2112 Engine::new(store)
2113 }
2114
2115 fn update_events(evs: &[Event]) -> usize {
2116 evs.iter().filter(|e| matches!(
2117 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::UpdateAvailable
2118 )).count()
2119 }
2120
2121 #[tokio::test]
2122 async fn poll_update_check_notifies_once_then_again_for_a_newer_version() {
2123 let engine = bare_engine();
2124 let mut rx = engine.subscribe();
2125 let source = Arc::new(FakeUpdateSource(std::sync::Mutex::new(Some(semver::Version::new(99, 0, 0)))));
2126 let poller = Poller::new(engine).with_update_source(source.clone());
2127
2128 poller.poll_update_check().await;
2129 assert_eq!(update_events(&drain_events(&mut rx)), 1, "first sighting of a newer version must notify");
2130
2131 poller.poll_update_check().await;
2132 assert_eq!(update_events(&drain_events(&mut rx)), 0, "same version again must not re-notify");
2133
2134 *source.0.lock().unwrap() = Some(semver::Version::new(100, 0, 0));
2135 poller.poll_update_check().await;
2136 assert_eq!(update_events(&drain_events(&mut rx)), 1, "an even newer version must notify again");
2137 }
2138
2139 #[tokio::test]
2140 async fn poll_update_check_no_notification_when_already_current() {
2141 let engine = bare_engine();
2142 let mut rx = engine.subscribe();
2143 let current: semver::Version = env!("CARGO_PKG_VERSION").parse().unwrap();
2144 let source = Arc::new(FakeUpdateSource(std::sync::Mutex::new(Some(current))));
2145 let poller = Poller::new(engine).with_update_source(source);
2146
2147 poller.poll_update_check().await;
2148 assert_eq!(update_events(&drain_events(&mut rx)), 0);
2149 }
2150
2151 #[tokio::test]
2152 async fn poll_update_check_no_notification_when_source_has_nothing() {
2153 let engine = bare_engine();
2154 let mut rx = engine.subscribe();
2155 let source = Arc::new(FakeUpdateSource(std::sync::Mutex::new(None)));
2156 let poller = Poller::new(engine).with_update_source(source);
2157
2158 poller.poll_update_check().await;
2159 assert_eq!(update_events(&drain_events(&mut rx)), 0);
2160 }
2161
2162 use std::{future::Future, pin::Pin};
2165
2166 struct FakeHarvestRunner {
2170 calls: tokio::sync::mpsc::UnboundedSender<(String, PathBuf, PathBuf)>,
2171 outcome: Result<(), String>,
2172 }
2173
2174 impl HarvestRunner for FakeHarvestRunner {
2175 fn run(
2176 &self,
2177 prompt: String,
2178 workspace: PathBuf,
2179 brain_path: PathBuf,
2180 ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>> {
2181 let _ = self.calls.send((prompt, workspace, brain_path));
2182 let outcome = self.outcome.clone();
2183 Box::pin(async move {
2184 outcome.map_err(|e| anyhow::anyhow!(e))
2185 })
2186 }
2187 }
2188
2189 fn init_diff_repo(feature_branch: &str, extra_file: Option<(&str, &str)>) -> std::path::PathBuf {
2194 let dir = tempfile::tempdir().unwrap().keep();
2195 let run = |args: &[&str]| {
2196 std::process::Command::new("git")
2197 .args(["-C", dir.to_str().unwrap()])
2198 .args(args)
2199 .output()
2200 .unwrap()
2201 };
2202 run(&["init", "-q", "-b", "main"]);
2203 run(&["config", "user.email", "test@example.com"]);
2204 run(&["config", "user.name", "Test"]);
2205 std::fs::write(dir.join("README.md"), "x").unwrap();
2206 run(&["add", "."]);
2207 run(&["commit", "-q", "-m", "init"]);
2208 run(&["checkout", "-q", "-b", feature_branch]);
2209 if let Some((name, contents)) = extra_file {
2210 std::fs::write(dir.join(name), contents).unwrap();
2211 run(&["add", name]);
2212 run(&["commit", "-q", "-m", "feature work"]);
2213 }
2214 dir
2215 }
2216
2217 fn nonexistent_config_path() -> std::path::PathBuf {
2221 tempfile::tempdir().unwrap().keep().join("nonexistent-ninox-config.toml")
2222 }
2223
2224 #[allow(clippy::await_holding_lock)]
2229 #[tokio::test]
2230 async fn metadata_sync_triggers_brain_harvest_exactly_once_on_pr_detection() {
2231 use crate::{config::ENV_TEST_GUARD, store::Store};
2232
2233 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2234 let prior = std::env::var("NINOX_CONFIG").ok();
2235 std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
2236
2237 let repo = init_diff_repo("feature-1", Some(("src.rs", "fn main() {}\n")));
2238 let workspace = repo.to_str().unwrap().to_string();
2239
2240 let sessions_dir = tempfile::tempdir().unwrap();
2241 let meta = serde_json::json!({"agentReportedPrNumber": "9"});
2242 std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
2243
2244 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2245 store.upsert_session(&test_session("s1", &workspace)).unwrap();
2246 let engine = Engine::new(store.clone());
2247
2248 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2249 let runner = Arc::new(FakeHarvestRunner { calls: tx, outcome: Ok(()) });
2250 let poller = Poller::new_with_harvest_runner(engine, runner);
2251
2252 poller.sync_sessions_metadata(sessions_dir.path()).await;
2253
2254 let (prompt, ..) = tokio::time::timeout(Duration::from_secs(2), rx.recv())
2255 .await
2256 .expect("harvest should be attempted")
2257 .expect("channel should not be closed");
2258 assert!(prompt.contains("src.rs") && prompt.contains("fn main"), "prompt must include the diff");
2259
2260 poller.sync_sessions_metadata(sessions_dir.path()).await;
2263 assert!(rx.try_recv().is_err(), "harvest must fire exactly once per session");
2264
2265 match prior {
2266 Some(v) => std::env::set_var("NINOX_CONFIG", v),
2267 None => std::env::remove_var("NINOX_CONFIG"),
2268 }
2269 }
2270
2271 #[allow(clippy::await_holding_lock)]
2276 #[tokio::test]
2277 async fn metadata_sync_brain_harvest_prefers_session_catalogue_path_over_default() {
2278 use crate::{config::ENV_TEST_GUARD, store::Store};
2279
2280 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2281 let prior = std::env::var("NINOX_CONFIG").ok();
2282 std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
2283
2284 let repo = init_diff_repo("feature-catalogue", Some(("src.rs", "fn main() {}\n")));
2285 let workspace = repo.to_str().unwrap().to_string();
2286
2287 let sessions_dir = tempfile::tempdir().unwrap();
2288 let meta = serde_json::json!({"agentReportedPrNumber": "13"});
2289 std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
2290
2291 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2292 let mut session = test_session("s1", &workspace);
2293 session.catalogue_path = Some("/custom/brain-catalogue".to_string());
2294 store.upsert_session(&session).unwrap();
2295 let engine = Engine::new(store.clone());
2296
2297 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2298 let runner = Arc::new(FakeHarvestRunner { calls: tx, outcome: Ok(()) });
2299 let poller = Poller::new_with_harvest_runner(engine, runner);
2300
2301 poller.sync_sessions_metadata(sessions_dir.path()).await;
2302
2303 let (_, _, brain_path) = tokio::time::timeout(Duration::from_secs(2), rx.recv())
2304 .await
2305 .expect("harvest should be attempted")
2306 .expect("channel should not be closed");
2307 assert_eq!(
2308 brain_path, PathBuf::from("/custom/brain-catalogue"),
2309 "harvest must target the session's own catalogue, not the global default",
2310 );
2311
2312 match prior {
2313 Some(v) => std::env::set_var("NINOX_CONFIG", v),
2314 None => std::env::remove_var("NINOX_CONFIG"),
2315 }
2316 }
2317
2318 #[allow(clippy::await_holding_lock)]
2321 #[tokio::test]
2322 async fn metadata_sync_skips_brain_harvest_when_disabled() {
2323 use crate::{config::ENV_TEST_GUARD, store::Store};
2324
2325 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2326 let config_dir = tempfile::tempdir().unwrap();
2327 let config_path = config_dir.path().join("config.toml");
2328 std::fs::write(&config_path, "port = 8080\nfont_size = 13.0\n\n[brain_harvest]\nenabled = false\n").unwrap();
2329 let prior = std::env::var("NINOX_CONFIG").ok();
2330 std::env::set_var("NINOX_CONFIG", &config_path);
2331
2332 let repo = init_diff_repo("feature-2", Some(("src.rs", "fn main() {}\n")));
2333 let workspace = repo.to_str().unwrap().to_string();
2334
2335 let sessions_dir = tempfile::tempdir().unwrap();
2336 let meta = serde_json::json!({"agentReportedPrNumber": "10"});
2337 std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
2338
2339 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2340 store.upsert_session(&test_session("s1", &workspace)).unwrap();
2341 let engine = Engine::new(store.clone());
2342
2343 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2344 let runner = Arc::new(FakeHarvestRunner { calls: tx, outcome: Ok(()) });
2345 let poller = Poller::new_with_harvest_runner(engine, runner);
2346
2347 poller.sync_sessions_metadata(sessions_dir.path()).await;
2348
2349 assert!(rx.try_recv().is_err(), "harvest must not fire when brain_harvest.enabled = false");
2350 let session = store.get_session("s1").unwrap().unwrap();
2351 assert_eq!(session.pr_number, Some(10), "PR detection must be unaffected by the disabled harvest");
2352 assert!(matches!(session.status, SessionStatus::PrOpen));
2353
2354 match prior {
2355 Some(v) => std::env::set_var("NINOX_CONFIG", v),
2356 None => std::env::remove_var("NINOX_CONFIG"),
2357 }
2358 }
2359
2360 #[allow(clippy::await_holding_lock)]
2364 #[tokio::test]
2365 async fn metadata_sync_skips_brain_harvest_for_trivial_diff() {
2366 use crate::{config::ENV_TEST_GUARD, store::Store};
2367
2368 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2369 let prior = std::env::var("NINOX_CONFIG").ok();
2370 std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
2371
2372 let repo = init_diff_repo("feature-3", None);
2374 let workspace = repo.to_str().unwrap().to_string();
2375
2376 let sessions_dir = tempfile::tempdir().unwrap();
2377 let meta = serde_json::json!({"agentReportedPrNumber": "11"});
2378 std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
2379
2380 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2381 store.upsert_session(&test_session("s1", &workspace)).unwrap();
2382 let engine = Engine::new(store.clone());
2383
2384 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2385 let runner = Arc::new(FakeHarvestRunner { calls: tx, outcome: Ok(()) });
2386 let poller = Poller::new_with_harvest_runner(engine, runner);
2387
2388 poller.sync_sessions_metadata(sessions_dir.path()).await;
2389
2390 assert!(rx.try_recv().is_err(), "harvest must not fire for an empty diff");
2391
2392 match prior {
2393 Some(v) => std::env::set_var("NINOX_CONFIG", v),
2394 None => std::env::remove_var("NINOX_CONFIG"),
2395 }
2396 }
2397
2398 #[allow(clippy::await_holding_lock)]
2402 #[tokio::test]
2403 async fn metadata_sync_survives_a_failing_brain_harvest() {
2404 use crate::{config::ENV_TEST_GUARD, store::Store};
2405
2406 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2407 let prior = std::env::var("NINOX_CONFIG").ok();
2408 std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
2409
2410 let repo = init_diff_repo("feature-4", Some(("src.rs", "fn main() {}\n")));
2411 let workspace = repo.to_str().unwrap().to_string();
2412
2413 let sessions_dir = tempfile::tempdir().unwrap();
2414 let meta = serde_json::json!({"agentReportedPrNumber": "12"});
2415 std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
2416
2417 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2418 store.upsert_session(&test_session("s1", &workspace)).unwrap();
2419 let engine = Engine::new(store.clone());
2420
2421 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2422 let runner = Arc::new(FakeHarvestRunner {
2423 calls: tx,
2424 outcome: Err("simulated claude -p failure".to_string()),
2425 });
2426 let poller = Poller::new_with_harvest_runner(engine, runner);
2427
2428 poller.sync_sessions_metadata(sessions_dir.path()).await;
2429
2430 let session = store.get_session("s1").unwrap().unwrap();
2431 assert_eq!(session.pr_number, Some(12), "PR detection must succeed regardless of harvest outcome");
2432 assert!(matches!(session.status, SessionStatus::PrOpen));
2433
2434 tokio::time::timeout(Duration::from_secs(2), rx.recv())
2435 .await
2436 .expect("the failing harvest must still be attempted")
2437 .expect("channel should not be closed");
2438
2439 match prior {
2440 Some(v) => std::env::set_var("NINOX_CONFIG", v),
2441 None => std::env::remove_var("NINOX_CONFIG"),
2442 }
2443 }
2444
2445 #[derive(Clone, Default)]
2448 struct CapturedLogs(Arc<std::sync::Mutex<Vec<String>>>);
2449
2450 impl CapturedLogs {
2451 fn contains(&self, needle: &str) -> bool {
2452 self.0.lock().unwrap().iter().any(|m| m.contains(needle))
2453 }
2454 }
2455
2456 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CapturedLogs {
2457 fn on_event(&self, event: &tracing::Event<'_>, _ctx: tracing_subscriber::layer::Context<'_, S>) {
2458 struct Visitor(String);
2459 impl tracing::field::Visit for Visitor {
2460 fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
2461 if field.name() == "message" {
2462 self.0 = format!("{value:?}");
2463 }
2464 }
2465 }
2466 let mut visitor = Visitor(String::new());
2467 event.record(&mut visitor);
2468 self.0.lock().unwrap().push(visitor.0);
2469 }
2470 }
2471
2472 struct PanickingHarvestRunner;
2476
2477 impl HarvestRunner for PanickingHarvestRunner {
2478 fn run(
2479 &self,
2480 _prompt: String,
2481 _workspace: PathBuf,
2482 _brain_path: PathBuf,
2483 ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>> {
2484 Box::pin(async move { panic!("simulated harvest panic") })
2485 }
2486 }
2487
2488 #[allow(clippy::await_holding_lock)]
2493 #[tokio::test]
2494 async fn metadata_sync_logs_a_warning_when_harvest_task_panics() {
2495 use crate::{config::ENV_TEST_GUARD, store::Store};
2496 use tracing_subscriber::{layer::SubscriberExt, Registry};
2497
2498 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2499 let prior = std::env::var("NINOX_CONFIG").ok();
2500 std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
2501
2502 let logs = CapturedLogs::default();
2503 let _log_guard = tracing::subscriber::set_default(Registry::default().with(logs.clone()));
2504
2505 let repo = init_diff_repo("feature-panic", Some(("src.rs", "fn main() {}\n")));
2506 let workspace = repo.to_str().unwrap().to_string();
2507
2508 let sessions_dir = tempfile::tempdir().unwrap();
2509 let meta = serde_json::json!({"agentReportedPrNumber": "20"});
2510 std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
2511
2512 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2513 store.upsert_session(&test_session("s1", &workspace)).unwrap();
2514 let engine = Engine::new(store.clone());
2515
2516 let poller = Poller::new_with_harvest_runner(engine, Arc::new(PanickingHarvestRunner));
2517 poller.sync_sessions_metadata(sessions_dir.path()).await;
2518
2519 let deadline = std::time::Instant::now() + Duration::from_secs(5);
2523 while !logs.contains("brain harvest task panicked") {
2524 assert!(std::time::Instant::now() < deadline, "timed out waiting for the panic to be logged");
2525 tokio::time::sleep(Duration::from_millis(20)).await;
2526 }
2527
2528 match prior {
2529 Some(v) => std::env::set_var("NINOX_CONFIG", v),
2530 None => std::env::remove_var("NINOX_CONFIG"),
2531 }
2532 }
2533
2534 struct OverlapDetectingHarvestRunner {
2539 calls: tokio::sync::mpsc::UnboundedSender<()>,
2540 active: Arc<std::sync::atomic::AtomicUsize>,
2541 overlapped: Arc<std::sync::atomic::AtomicBool>,
2542 }
2543
2544 impl HarvestRunner for OverlapDetectingHarvestRunner {
2545 fn run(
2546 &self,
2547 _prompt: String,
2548 _workspace: PathBuf,
2549 _brain_path: PathBuf,
2550 ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>> {
2551 let _ = self.calls.send(());
2552 let active = self.active.clone();
2553 let overlapped = self.overlapped.clone();
2554 Box::pin(async move {
2555 if active.fetch_add(1, std::sync::atomic::Ordering::SeqCst) > 0 {
2556 overlapped.store(true, std::sync::atomic::Ordering::SeqCst);
2557 }
2558 tokio::time::sleep(Duration::from_millis(50)).await;
2559 active.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
2560 Ok(())
2561 })
2562 }
2563 }
2564
2565 #[allow(clippy::await_holding_lock)]
2571 #[tokio::test]
2572 async fn concurrent_harvests_to_the_same_vault_do_not_overlap() {
2573 use crate::{config::ENV_TEST_GUARD, store::Store};
2574
2575 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2576 let prior = std::env::var("NINOX_CONFIG").ok();
2577 std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
2578
2579 let repo1 = init_diff_repo("feature-vault-1", Some(("a.rs", "fn a() {}\n")));
2580 let repo2 = init_diff_repo("feature-vault-2", Some(("b.rs", "fn b() {}\n")));
2581
2582 let sessions_dir = tempfile::tempdir().unwrap();
2583 for (id, pr) in [("s1", "30"), ("s2", "31")] {
2584 let meta = serde_json::json!({"agentReportedPrNumber": pr});
2585 std::fs::write(sessions_dir.path().join(format!("{id}.json")), serde_json::to_string(&meta).unwrap()).unwrap();
2586 }
2587
2588 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2589 store.upsert_session(&test_session("s1", repo1.to_str().unwrap())).unwrap();
2590 store.upsert_session(&test_session("s2", repo2.to_str().unwrap())).unwrap();
2591 let engine = Engine::new(store.clone());
2592
2593 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2594 let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2595 let overlapped = Arc::new(std::sync::atomic::AtomicBool::new(false));
2596 let runner = Arc::new(OverlapDetectingHarvestRunner {
2597 calls: tx, active: active.clone(), overlapped: overlapped.clone(),
2598 });
2599 let poller = Poller::new_with_harvest_runner(engine, runner);
2600
2601 poller.sync_sessions_metadata(sessions_dir.path()).await;
2602
2603 for _ in 0..2 {
2604 tokio::time::timeout(Duration::from_secs(2), rx.recv())
2605 .await
2606 .expect("both harvests should be attempted")
2607 .expect("channel should not be closed");
2608 }
2609 tokio::time::sleep(Duration::from_millis(150)).await;
2612
2613 assert!(
2614 !overlapped.load(std::sync::atomic::Ordering::SeqCst),
2615 "concurrent harvests to the same vault must not run HarvestRunner::run concurrently",
2616 );
2617
2618 match prior {
2619 Some(v) => std::env::set_var("NINOX_CONFIG", v),
2620 None => std::env::remove_var("NINOX_CONFIG"),
2621 }
2622 }
2623
2624 #[test]
2630 fn vault_lock_treats_equivalent_paths_as_the_same_vault() {
2631 use crate::store::Store;
2632
2633 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2634 let engine = Engine::new(store);
2635 let poller = Poller::new_with_harvest_runner(engine, Arc::new(ClaudeHarvestRunner));
2636
2637 let dir = tempfile::tempdir().unwrap();
2638 let canonical = dir.path().to_path_buf();
2639 let with_trailing_slash = PathBuf::from(format!("{}/", canonical.display()));
2640
2641 let lock_a = poller.vault_lock(&canonical);
2642 let lock_b = poller.vault_lock(&with_trailing_slash);
2643
2644 assert!(
2645 Arc::ptr_eq(&lock_a, &lock_b),
2646 "syntactically different paths to the same physical vault must share one lock",
2647 );
2648 }
2649 #[tokio::test]
2655 async fn merge_detection_fires_for_terminated_session() {
2656 use crate::store::Store;
2657
2658 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2659 let mut s = test_session("w1", "/ws");
2660 s.status = SessionStatus::Terminated;
2661 s.orchestrator_id = Some("orch1".into());
2662 s.pr_number = Some(42);
2663 store.upsert_session(&s).unwrap();
2664
2665 let engine = Engine::new(store.clone());
2666 let mut rx = engine.subscribe();
2667 let poller = Poller::new(engine);
2668
2669 let handled = poller.handle_merge_detection(&s, 42, true).await;
2670 assert!(handled, "merge detection must run for a Terminated session");
2671
2672 let after = store.get_session("w1").unwrap().unwrap();
2673 assert!(matches!(after.status, SessionStatus::Done), "merged session transitions to Done");
2674 assert!(after.terminal_at.is_some(), "Done via merge detection must stamp terminal_at");
2675
2676 let events = drain_events(&mut rx);
2677 let merged_notifs = events.iter().filter(|e| matches!(
2678 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkerDone
2679 )).count();
2680 assert_eq!(merged_notifs, 1, "exactly one WorkerDone notification for the merge");
2681 }
2682
2683 #[tokio::test]
2688 async fn merge_detection_does_not_fire_twice_for_the_same_session() {
2689 use crate::store::Store;
2690
2691 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2692 let mut s = test_session("w1", "/ws");
2693 s.status = SessionStatus::PrOpen;
2694 s.orchestrator_id = Some("orch1".into());
2695 s.pr_number = Some(7);
2696 store.upsert_session(&s).unwrap();
2697
2698 let engine = Engine::new(store.clone());
2699 let mut rx = engine.subscribe();
2700 let poller = Poller::new(engine);
2701
2702 let first = poller.handle_merge_detection(&s, 7, true).await;
2703 assert!(first, "first tick handles the merge");
2704
2705 let updated = store.get_session("w1").unwrap().unwrap();
2708 let second = poller.handle_merge_detection(&updated, 7, true).await;
2709 assert!(!second, "an already-Done session must not re-fire merge detection");
2710
2711 let events = drain_events(&mut rx);
2712 let merged_notifs = events.iter().filter(|e| matches!(
2713 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkerDone
2714 )).count();
2715 assert_eq!(merged_notifs, 1, "no duplicate WorkerDone notification across ticks");
2716 }
2717
2718 #[tokio::test]
2719 async fn handle_merge_detection_is_noop_when_pr_not_merged() {
2720 use crate::store::Store;
2721
2722 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2723 let s = test_session("w1", "/ws");
2724 store.upsert_session(&s).unwrap();
2725 let engine = Engine::new(store.clone());
2726 let poller = Poller::new(engine);
2727
2728 let handled = poller.handle_merge_detection(&s, 1, false).await;
2729 assert!(!handled);
2730 assert!(matches!(store.get_session("w1").unwrap().unwrap().status, SessionStatus::Working));
2731 }
2732
2733 #[tokio::test]
2740 async fn sweep_retired_sessions_purges_only_past_the_retention_window() {
2741 use crate::store::Store;
2742
2743 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2744 let retention = SessionRetentionConfig { done_retention_days: 2 };
2745 let now = now_millis();
2746 let retention_ms = retention.retention_millis();
2747
2748 let mut expired = test_session("expired", "/ws");
2749 expired.status = SessionStatus::Done;
2750 expired.terminal_at = Some(now - retention_ms - 1_000);
2751 store.upsert_session(&expired).unwrap();
2752
2753 let mut fresh = test_session("fresh", "/ws");
2754 fresh.status = SessionStatus::Done;
2755 fresh.terminal_at = Some(now - 1_000);
2756 store.upsert_session(&fresh).unwrap();
2757
2758 let engine = Engine::new(store.clone());
2759 let mut rx = engine.subscribe();
2760 let poller = Poller::new(engine);
2761
2762 poller.sweep_retired_sessions(&retention).await;
2763
2764 assert!(store.get_session("expired").unwrap().is_none(), "past-retention session must be purged");
2765 assert!(store.get_session("fresh").unwrap().is_some(), "within-retention session must survive");
2766
2767 let events = drain_events(&mut rx);
2768 assert!(events.iter().any(|e| matches!(e, Event::SessionDone(id) if id == "expired")));
2769 }
2770
2771 #[tokio::test]
2776 async fn sweep_retired_sessions_purges_sessions_with_no_terminal_at_immediately() {
2777 use crate::store::Store;
2778
2779 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2780 let mut s = test_session("user-killed", "/ws");
2781 s.status = SessionStatus::Terminated;
2782 s.terminal_at = None;
2783 store.upsert_session(&s).unwrap();
2784
2785 let engine = Engine::new(store.clone());
2786 let poller = Poller::new(engine);
2787
2788 poller.sweep_retired_sessions(&SessionRetentionConfig::default()).await;
2789
2790 assert!(store.get_session("user-killed").unwrap().is_none());
2791 }
2792
2793 #[tokio::test]
2796 async fn sweep_retired_sessions_never_purges_orchestrator_sessions() {
2797 use crate::store::Store;
2798
2799 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2800 store.upsert_orchestrator(&crate::types::Orchestrator {
2801 id: "orch1".into(), name: "orch".into(), created_at: 0,
2802 }).unwrap();
2803 let mut s = test_session("orch1", "/ws");
2804 s.status = SessionStatus::Done;
2805 s.terminal_at = Some(0); store.upsert_session(&s).unwrap();
2807
2808 let engine = Engine::new(store.clone());
2809 let poller = Poller::new(engine);
2810
2811 poller.sweep_retired_sessions(&SessionRetentionConfig::default()).await;
2812
2813 assert!(store.get_session("orch1").unwrap().is_some(), "orchestrator sessions are never auto-purged");
2814 }
2815
2816 #[tokio::test]
2819 async fn sweep_retired_sessions_ignores_non_terminal_sessions() {
2820 use crate::store::Store;
2821
2822 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2823 let mut s = test_session("still-working", "/ws");
2824 s.status = SessionStatus::Working;
2825 s.terminal_at = None;
2826 store.upsert_session(&s).unwrap();
2827
2828 let engine = Engine::new(store.clone());
2829 let poller = Poller::new(engine);
2830
2831 poller.sweep_retired_sessions(&SessionRetentionConfig::default()).await;
2832
2833 assert!(store.get_session("still-working").unwrap().is_some());
2834 }
2835
2836 #[tokio::test]
2842 async fn sweep_retired_sessions_notifies_orchestrator_for_never_merge_detected_session() {
2843 use crate::store::Store;
2844
2845 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2846 let mut s = test_session("w1", "/ws");
2847 s.status = SessionStatus::Terminated; s.orchestrator_id = Some("orch1".into());
2849 s.terminal_at = Some(0); store.upsert_session(&s).unwrap();
2851
2852 let engine = Engine::new(store.clone());
2853 let mut rx = engine.subscribe();
2854 let poller = Poller::new(engine);
2855
2856 poller.sweep_retired_sessions(&SessionRetentionConfig::default()).await;
2857
2858 assert!(store.get_session("w1").unwrap().is_none(), "session must still be purged");
2859
2860 let events = drain_events(&mut rx);
2861 let retired_notifs = events.iter().filter(|e| matches!(
2862 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkerRetired
2863 && n.id == "retired-w1"
2864 )).count();
2865 assert_eq!(retired_notifs, 1, "orchestrator must be told its worker was retired");
2866 }
2867
2868 #[tokio::test]
2873 async fn sweep_retired_sessions_does_not_double_notify_a_merge_detected_session() {
2874 use crate::store::Store;
2875
2876 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2877 let mut s = test_session("w1", "/ws");
2878 s.orchestrator_id = Some("orch1".into());
2879 s.pr_number = Some(7);
2880 store.upsert_session(&s).unwrap();
2881
2882 let engine = Engine::new(store.clone());
2883 let mut rx = engine.subscribe();
2884 let poller = Poller::new(engine);
2885
2886 assert!(poller.handle_merge_detection(&s, 7, true).await);
2889 drain_events(&mut rx); let mut done = store.get_session("w1").unwrap().unwrap();
2893 done.terminal_at = Some(0);
2894 store.upsert_session(&done).unwrap();
2895
2896 poller.sweep_retired_sessions(&SessionRetentionConfig::default()).await;
2897
2898 assert!(store.get_session("w1").unwrap().is_none(), "session must still be purged");
2899
2900 let events = drain_events(&mut rx);
2901 let retired_notifs = events.iter().filter(|e| matches!(
2902 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkerRetired
2903 )).count();
2904 assert_eq!(retired_notifs, 0, "must not re-notify a session already told about its merge");
2905 }
2906}