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 usage,
11 },
12 types::{
13 CIStatus, Comment, Notification, NotificationKind, PrId, Session, SessionStatus, PR,
14 },
15};
16use std::{
17 collections::HashMap,
18 path::{Path, PathBuf},
19 sync::Arc,
20 time::Duration,
21};
22use tokio_util::sync::CancellationToken;
23
24type ContextSnapshot = (f64, Option<f64>, Option<u64>);
27
28pub(crate) fn now_millis() -> i64 {
32 std::time::SystemTime::now()
33 .duration_since(std::time::UNIX_EPOCH)
34 .map(|d| d.as_millis() as i64)
35 .unwrap_or(0)
36}
37
38fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
42 if let Some(s) = payload.downcast_ref::<&str>() {
43 s.to_string()
44 } else if let Some(s) = payload.downcast_ref::<String>() {
45 s.clone()
46 } else {
47 "<non-string panic payload>".to_string()
48 }
49}
50
51pub struct Poller {
52 engine: Arc<Engine>,
53 enrichment_cache: Arc<std::sync::Mutex<EnrichmentCache>>,
54 context_cache: Arc<std::sync::Mutex<HashMap<String, ContextSnapshot>>>,
58 harvest_runner: Arc<dyn HarvestRunner>,
62 vault_locks: Arc<std::sync::Mutex<HashMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>>,
70}
71
72impl Poller {
73 pub fn new(engine: Arc<Engine>) -> Self {
74 Self::new_with_harvest_runner(engine, Arc::new(ClaudeHarvestRunner))
75 }
76
77 pub fn new_with_harvest_runner(engine: Arc<Engine>, harvest_runner: Arc<dyn HarvestRunner>) -> Self {
78 Self {
79 engine,
80 enrichment_cache: Arc::new(std::sync::Mutex::new(HashMap::new())),
81 context_cache: Arc::new(std::sync::Mutex::new(HashMap::new())),
82 harvest_runner,
83 vault_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
84 }
85 }
86
87 fn vault_lock(&self, path: &Path) -> Arc<tokio::sync::Mutex<()>> {
90 let key = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
97 let mut locks = self.vault_locks.lock().unwrap_or_else(|e| e.into_inner());
98 locks
99 .entry(key)
100 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
101 .clone()
102 }
103
104 pub async fn start(self, token: CancellationToken) {
105 let mut pid_interval = tokio::time::interval(Duration::from_secs(5));
106 let mut usage_interval = tokio::time::interval(Duration::from_secs(10));
107 let mut github_interval = tokio::time::interval(Duration::from_secs(30));
108 github_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
110 usage_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
111 loop {
112 tokio::select! {
113 _ = token.cancelled() => break,
114 _ = pid_interval.tick() => {
115 self.poll_pids().await;
116 self.poll_context_updates().await;
117 let retention = AppConfig::load()
118 .unwrap_or_default()
119 .session_retention;
120 self.sweep_retired_sessions(&retention).await;
121 }
122 _ = usage_interval.tick() => self.poll_usage().await,
123 _ = github_interval.tick() => {
124 self.poll_pr_reconciliation().await;
128 self.poll_github().await;
129 }
130 }
131 }
132 }
133
134 async fn poll_pids(&self) {
137 self.sync_sessions_metadata(&AppConfig::sessions_dir()).await;
140
141 let Ok(sessions) = self.engine.store.list_sessions() else { return };
142 for mut session in sessions {
143 if matches!(session.status, SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted) {
144 continue;
145 }
146 if let Some(pid) = session.pid {
147 if !is_pid_alive(pid) {
148 session.status = SessionStatus::Terminated;
149 session.terminal_at = Some(now_millis());
150 let _ = self.engine.store.upsert_session(&session);
151 self.engine.emit(Event::SessionUpdated(session));
152 }
153 }
154 }
155 }
156
157 async fn sync_sessions_metadata(&self, sessions_dir: &std::path::Path) {
165 let Ok(sessions) = self.engine.store.list_sessions() else { return };
166 for mut session in sessions {
167 self.deliver_work_requests(&session, sessions_dir).await;
171
172 if matches!(session.status, SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted) {
173 continue;
174 }
175 let Ok(meta) = hooks::read_session_metadata(sessions_dir, &session.id) else {
176 continue;
177 };
178
179 if session.pr_number.is_none() {
181 if let Some(first) = meta.pr_reports.first() {
182 session.pr_number = Some(first.number);
183 session.status = SessionStatus::PrOpen;
184 let _ = self.engine.store.upsert_session(&session);
185 self.engine.emit(Event::SessionUpdated(session.clone()));
186 tracing::info!(
187 "session {} PR #{} detected via metadata hook",
188 session.id, first.number
189 );
190 self.trigger_brain_harvest(&session).await;
191 }
192 }
193
194 let Some(tracked) = session.pr_number else { continue };
196
197 for report in meta.pr_reports.iter().filter(|r| r.number != tracked) {
202 if let Ok(None) = self.engine.store.get_pr(report.number as i64) {
203 let url = report.url.clone().unwrap_or_else(|| {
204 format!("https://github.com/{}/pull/{}", session.repo, report.number)
205 });
206 let _ = self.engine.store.upsert_pr(&PR {
207 id: report.number as i64,
208 number: report.number,
209 title: String::new(),
210 url,
211 body: String::new(),
212 session_id: session.id.clone(),
213 });
214 }
215 }
216
217 let notified = hooks::read_notified_extra_prs(sessions_dir, &session.id);
219 let mut fresh: Vec<(u64, Option<String>)> = Vec::new();
220 for report in meta.pr_reports.iter()
221 .filter(|r| r.number != tracked && !notified.contains(&r.number))
222 {
223 self.engine.emit(Event::Notification(Notification {
224 id: format!("extra-pr-{}-{}", session.id, report.number),
225 kind: NotificationKind::ExtraPr,
226 title: format!("Extra PR — {}", session.name),
227 body: format!("#{} opened beyond tracked #{tracked}", report.number),
228 session_id: Some(session.id.clone()),
229 created_at: now_millis(),
230 }));
231 fresh.push((report.number, report.url.clone()));
232 }
233 if fresh.is_empty() {
234 continue;
235 }
236 let numbers: Vec<u64> = fresh.iter().map(|(n, _)| *n).collect();
237 if let Err(e) = hooks::mark_extra_prs_notified(sessions_dir, &session.id, &numbers) {
238 tracing::warn!("mark extra PRs notified for {}: {e}", session.id);
239 }
240 if let Some(orch) = session.orchestrator_id.clone() {
241 let msg = crate::lifecycle::reactions::format_extra_pr_reaction(
242 &session, tracked, &fresh,
243 );
244 if let Err(e) = self.engine.send_to_session(&orch, &msg).await {
245 tracing::warn!("send extra-PR reaction to orchestrator {orch}: {e}");
246 }
247 }
248 }
249 }
250
251 async fn trigger_brain_harvest(&self, session: &Session) {
266 let config = AppConfig::load().unwrap_or_default();
267 if !config.brain_harvest.enabled {
268 return;
269 }
270 let Some(workspace) = session.workspace_path.clone() else { return };
271 let workspace_path: PathBuf = workspace.into();
272 let brain_path: PathBuf = session.catalogue_path.clone()
278 .map(PathBuf::from)
279 .unwrap_or_else(|| config.resolved_brain_path());
280 let runner = self.harvest_runner.clone();
281 let session_id = session.id.clone();
282 let panic_session_id = session_id.clone();
283 let vault_lock = self.vault_lock(&brain_path);
284
285 let handle = tokio::spawn(async move {
286 let Some(diff) = brain_harvest::compute_nontrivial_diff(&workspace_path).await else {
287 tracing::info!("brain harvest skipped for {session_id}: no non-trivial diff");
288 return;
289 };
290 let prompt = brain_harvest::build_harvest_prompt(&session_id, &diff);
291
292 let _guard = vault_lock.lock().await;
296 if let Err(e) = runner.run(prompt, workspace_path, brain_path).await {
297 tracing::warn!("brain harvest failed for session {session_id}: {e}");
298 }
299 });
300
301 tokio::spawn(async move {
302 if let Err(join_err) = handle.await {
303 if join_err.is_panic() {
304 let payload = join_err.into_panic();
305 tracing::warn!(
306 "brain harvest task panicked for session {panic_session_id}: {}",
307 panic_message(payload.as_ref()),
308 );
309 } else {
310 tracing::warn!("brain harvest task for session {panic_session_id} was cancelled");
311 }
312 }
313 });
314 }
315
316 async fn deliver_work_requests(&self, session: &crate::types::Session, sessions_dir: &std::path::Path) {
319 let pending = match hooks::read_pending_work_requests(sessions_dir, &session.id) {
320 Ok(p) if !p.is_empty() => p,
321 _ => return,
322 };
323 for request in &pending {
324 self.engine.emit(Event::Notification(Notification {
325 id: format!("work-request-{}", request.id),
326 kind: NotificationKind::WorkRequested,
327 title: format!("Work requested — {}", session.name),
328 body: request.description.clone(),
329 session_id: Some(session.id.clone()),
330 created_at: now_millis(),
331 }));
332 if let Some(orch) = session.orchestrator_id.clone() {
333 let msg = crate::lifecycle::reactions::format_work_request_reaction(
334 session, &request.description,
335 );
336 if let Err(e) = self.engine.send_to_session(&orch, &msg).await {
337 tracing::warn!("send work request to orchestrator {orch}: {e}");
338 }
339 }
340 }
341 let ids: Vec<String> = pending.iter().map(|r| r.id.clone()).collect();
345 if let Err(e) = hooks::mark_work_requests_delivered(sessions_dir, &session.id, &ids) {
346 tracing::warn!("mark work requests delivered for {}: {e}", session.id);
347 }
348 }
349
350 async fn poll_usage(&self) {
359 let Ok(sessions) = self.engine.store.list_sessions() else { return };
360 for mut session in sessions {
361 if matches!(session.status, SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted) {
362 continue;
363 }
364 let Some(workspace) = session.workspace_path.clone() else { continue };
365 let Some(snapshot) = usage::ingest_usage_for_workspace(&workspace) else { continue };
366
367 let cost_changed = (session.cost_usd - snapshot.cost_usd).abs() > 1e-9;
368 let context_changed = session.context_tokens != Some(snapshot.context_tokens);
369 if !cost_changed && !context_changed {
370 continue;
371 }
372
373 session.cost_usd = snapshot.cost_usd;
374 session.context_tokens = Some(snapshot.context_tokens);
375 if session.model.is_none() {
376 session.model = snapshot.model;
377 }
378 let _ = self.engine.store.upsert_session(&session);
379 self.engine.emit(Event::SessionUpdated(session));
380 }
381 }
382
383 async fn poll_context_updates(&self) {
394 let Ok(sessions) = self.engine.store.list_sessions() else { return };
395 let mut changed = Vec::new();
396 {
397 let mut cache = self.context_cache.lock().unwrap();
398 for session in sessions {
399 let key = (session.cost_usd, session.context_used_pct, session.context_total_tokens);
400 if let Some(prev) = cache.insert(session.id.clone(), key) {
404 if prev != key {
405 changed.push(session);
406 }
407 }
408 }
409 }
410 for session in changed {
411 self.engine.emit(Event::SessionUpdated(session));
412 }
413 }
414
415 async fn poll_github(&self) {
418 let Some(gh) = &self.engine.github else { return };
419 let Ok(sessions) = self.engine.store.list_sessions() else { return };
420
421 for mut session in sessions {
422 if matches!(session.status, SessionStatus::Done) {
430 continue;
431 }
432 let Some(pr_number) = session.pr_number else { continue };
433
434 let mut found: Option<(String, u64, crate::github::PrStatus)> = None;
446 let mut last_err: Option<anyhow::Error> = None;
447 let mut attempted = false;
448 if !session.repo.is_empty() {
449 if let Some((owner, repo)) = split_repo(&session.repo) {
450 attempted = true;
451 match gh.get_pr_status(&owner, &repo, pr_number).await {
452 Ok(s) => found = Some((session.repo.clone(), pr_number, s)),
453 Err(e) => last_err = Some(e),
454 }
455 }
456 }
457 if found.is_none() {
458 if let Some(workspace) = session.workspace_path.clone() {
459 if let Some(branch) = crate::github::current_branch(&workspace) {
460 for repo_slug in crate::github::candidate_repos(&workspace) {
461 if repo_slug == session.repo {
462 continue; }
464 let Some((owner, repo)) = split_repo(&repo_slug) else { continue };
465 attempted = true;
466 let pr_ref = match gh.find_open_pr_for_branch(&owner, &repo, &branch).await {
467 Ok(Some(r)) => r,
468 Ok(None) => continue,
469 Err(e) => { last_err = Some(e); continue; }
470 };
471 match gh.get_pr_status(&owner, &repo, pr_ref.number).await {
472 Ok(s) => { found = Some((repo_slug, pr_ref.number, s)); break; }
473 Err(e) => { last_err = Some(e); continue; }
474 }
475 }
476 }
477 }
478 }
479 if !attempted {
480 continue; }
482 let Some((resolved_repo, pr_number, pr_status)) = found else {
483 if let Some(e) = last_err {
484 tracing::warn!("github pr status for {}: {e}", session.id);
485 }
486 self.notify_github_lookup_failed(&session);
487 continue;
488 };
489 self.clear_github_lookup_failed(&session.id);
490
491 if resolved_repo != session.repo || Some(pr_number) != session.pr_number {
497 tracing::info!(
498 "session {} repo/PR corrected {}#{:?} -> {resolved_repo}#{pr_number}",
499 session.id, session.repo, session.pr_number,
500 );
501 session.repo = resolved_repo;
502 session.pr_number = Some(pr_number);
503 let _ = self.engine.store.upsert_session(&session);
504 }
505 let Some((owner, repo)) = split_repo(&session.repo) else { continue };
506
507 let pr_id: PrId = pr_number as i64;
508
509 if self.handle_merge_detection(&session, pr_number, pr_status.merged).await {
511 continue; }
513
514 {
516 let pr = PR {
517 id: pr_id,
518 number: pr_number,
519 title: pr_status.title.clone(),
520 url: format!("https://github.com/{owner}/{repo}/pull/{pr_number}"),
521 body: String::new(),
522 session_id: session.id.clone(),
523 };
524 let _ = self.engine.store.upsert_pr(&pr);
525 self.engine.emit(Event::PrOpened { session_id: session.id.clone(), pr });
526 }
527
528 let checks = match gh.get_ci_checks(&owner, &repo, &pr_status.head_sha).await {
530 Ok(c) => c,
531 Err(e) => { tracing::warn!("github ci checks: {e}"); vec![] }
532 };
533 let ci = summarize_checks(pr_id, &checks);
534 let _ = self.engine.store.upsert_ci_status(&ci);
535 self.engine.emit(Event::CiUpdated { pr_id, status: ci.clone() });
536
537 let (newly_failing, ci_reaction_already_sent) = {
539 let mut cache = self.enrichment_cache.lock().unwrap();
540 let state = cache.entry(session.id.clone()).or_default();
541
542 let newly_failing = state.prev_failing.is_none_or(|p| p == 0)
543 && ci.failing > 0;
544 state.prev_failing = Some(ci.failing);
545
546 let already_sent = state.ci_reaction_sent;
547 if newly_failing && !already_sent {
548 state.ci_reaction_sent = true;
549 }
550 if ci.failing == 0 {
551 state.ci_reaction_sent = false;
552 }
553 (newly_failing, already_sent)
554 };
555
556 if newly_failing && !ci_reaction_already_sent {
557 self.engine.emit(Event::Notification(Notification {
558 id: format!("ci-{}", session.id),
559 kind: NotificationKind::CiFailure,
560 title: format!("CI failing — {}", session.name),
561 body: format!("{}/{} checks failing", ci.failing, ci.total),
562 session_id: Some(session.id.clone()),
563 created_at: now_millis(),
564 }));
565 let failing_names: Vec<String> = checks.iter()
567 .filter(|c| c.conclusion.as_deref() == Some("failure")
568 || c.conclusion.as_deref() == Some("timed_out"))
569 .map(|c| c.name.clone())
570 .collect();
571 let msg = crate::lifecycle::reactions::format_ci_reaction(
572 &session, &ci, &failing_names
573 );
574 if let Err(e) = self.engine.send_to_session(&session.id, &msg).await {
575 tracing::warn!("send ci reaction to {}: {e}", session.id);
576 }
577 }
578
579 let threads = match gh.get_review_threads(&owner, &repo, pr_number).await {
581 Ok(t) => t,
582 Err(e) => { tracing::warn!("github review threads: {e}"); vec![] }
583 };
584
585 let has_changes_requested = threads.iter().any(|t| t.state == "CHANGES_REQUESTED");
586
587 let (has_new, review_reaction_already_sent, new_comments) = {
588 let mut cache = self.enrichment_cache.lock().unwrap();
589 let state = cache.entry(session.id.clone()).or_default();
590 let mut has_new = false;
591 let mut new_comments: Vec<Comment> = Vec::new();
592
593 for thread in &threads {
594 if thread.state == "CHANGES_REQUESTED"
595 && !state.seen_comment_ids.contains(&thread.id)
596 {
597 state.seen_comment_ids.insert(thread.id);
598 has_new = true;
599 let comment = Comment {
600 id: thread.id,
601 pr_id,
602 author: thread.author.clone(),
603 body: thread.body.clone(),
604 path: thread.path.clone(),
605 line: thread.line,
606 created_at: 0,
607 };
608 let _ = self.engine.store.upsert_comment(&comment);
609 self.engine.emit(Event::ReviewComment { pr_id, comment: comment.clone() });
610 new_comments.push(comment);
611 }
612 }
613
614 let already_sent = state.review_reaction_sent;
615 if has_new && !already_sent {
616 state.review_reaction_sent = true;
617 }
618 if !has_changes_requested {
620 state.review_reaction_sent = false;
621 }
622 (has_new, already_sent, new_comments)
623 };
624
625 let new_status = derive_session_status(&session.status, &pr_status, &ci, has_changes_requested);
627 let mut updated = session.clone();
628 updated.status = new_status;
629 if updated.status != session.status {
630 let _ = self.engine.store.upsert_session(&updated);
631 self.engine.emit(Event::SessionUpdated(updated.clone()));
632 }
633
634 if has_new && !review_reaction_already_sent {
635 self.engine.emit(Event::Notification(Notification {
636 id: format!("review-{}", session.id),
637 kind: NotificationKind::PrNeedsAttention,
638 title: format!("Review comments — {}", session.name),
639 body: "Changes requested on your PR".to_string(),
640 session_id: Some(session.id.clone()),
641 created_at: now_millis(),
642 }));
643 if !new_comments.is_empty() {
644 let msg = crate::lifecycle::reactions::format_review_reaction(
645 &session, &new_comments
646 );
647 if let Err(e) = self.engine.send_to_session(&session.id, &msg).await {
648 tracing::warn!("send review reaction to {}: {e}", session.id);
649 }
650 }
651 }
652 }
653 }
654
655 fn notify_github_lookup_failed(&self, session: &crate::types::Session) {
659 let already_notified = {
660 let mut cache = self.enrichment_cache.lock().unwrap();
661 let state = cache.entry(session.id.clone()).or_default();
662 let already = state.github_lookup_failed_notified;
663 state.github_lookup_failed_notified = true;
664 already
665 };
666 if already_notified {
667 return;
668 }
669 self.engine.emit(Event::Notification(Notification {
670 id: format!("github-lookup-failed-{}", session.id),
671 kind: NotificationKind::GithubLookupFailed,
672 title: format!("GitHub lookup failing — {}", session.name),
673 body: "PR status/CI/review polling failed against every configured remote"
674 .to_string(),
675 session_id: Some(session.id.clone()),
676 created_at: now_millis(),
677 }));
678 }
679
680 fn clear_github_lookup_failed(&self, session_id: &str) {
683 let mut cache = self.enrichment_cache.lock().unwrap();
684 if let Some(state) = cache.get_mut(session_id) {
685 state.github_lookup_failed_notified = false;
686 }
687 }
688
689 async fn poll_pr_reconciliation(&self) {
700 let Some(gh) = &self.engine.github else { return };
701 let Ok(sessions) = self.engine.store.list_sessions() else { return };
702
703 for mut session in sessions {
704 if session.pr_number.is_some() {
705 continue;
706 }
707 if matches!(session.status, SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted) {
708 continue;
709 }
710 let Some(workspace) = session.workspace_path.clone() else { continue };
711 let Some(branch) = crate::github::current_branch(&workspace) else { continue };
712
713 for repo_slug in crate::github::candidate_repos(&workspace) {
714 let Some((owner, repo)) = split_repo(&repo_slug) else { continue };
715 match gh.find_open_pr_for_branch(&owner, &repo, &branch).await {
716 Ok(Some(pr_ref)) => {
717 session.pr_number = Some(pr_ref.number);
718 session.repo = repo_slug.clone();
719 session.status = SessionStatus::PrOpen;
720 let _ = self.engine.store.upsert_session(&session);
721 self.engine.emit(Event::SessionUpdated(session.clone()));
722 tracing::info!(
723 "session {} PR #{} detected via reconciliation ({repo_slug}, branch {branch})",
724 session.id, pr_ref.number,
725 );
726 break;
727 }
728 Ok(None) => continue,
729 Err(e) => {
730 tracing::warn!("reconciliation lookup {repo_slug} branch {branch}: {e}");
731 continue;
732 }
733 }
734 }
735 }
736 }
737 async fn handle_merge_detection(
749 &self,
750 session: &Session,
751 pr_number: u64,
752 pr_merged: bool,
753 ) -> bool {
754 if !pr_merged || matches!(session.status, SessionStatus::Done) {
755 return false;
756 }
757 self.engine.emit(Event::Notification(Notification {
758 id: format!("merged-{}", session.id),
759 kind: NotificationKind::WorkerDone,
760 title: format!("PR merged — {}", session.name),
761 body: format!("#{} merged successfully", pr_number),
762 session_id: Some(session.id.clone()),
763 created_at: now_millis(),
764 }));
765 if let Some(orch) = session.orchestrator_id.clone() {
768 let msg = crate::lifecycle::reactions::format_worker_done_reaction(session, pr_number);
769 if let Err(e) = self.engine.send_to_session(&orch, &msg).await {
770 tracing::warn!("send worker-done reaction to orchestrator {orch}: {e}");
771 }
772 }
773 if let Err(e) = self.engine.cleanup_session(&session.id).await {
774 tracing::warn!("cleanup_session {}: {e}", session.id);
775 }
776 {
778 let mut cache = self.enrichment_cache.lock().unwrap();
779 cache.remove(&session.id);
780 }
781 true
782 }
783
784 async fn sweep_retired_sessions(&self, retention: &SessionRetentionConfig) {
797 let Ok(sessions) = self.engine.store.list_sessions() else { return };
798 let Ok(orchestrators) = self.engine.store.list_orchestrators() else { return };
799 let orch_ids: std::collections::HashSet<&str> =
800 orchestrators.iter().map(|o| o.id.as_str()).collect();
801
802 let now = now_millis();
803 let retention_ms = retention.retention_millis();
804
805 for session in sessions {
806 if !matches!(session.status, SessionStatus::Done | SessionStatus::Terminated) {
807 continue;
808 }
809 if orch_ids.contains(session.id.as_str()) {
810 continue;
811 }
812 let expired = match session.terminal_at {
813 Some(t) => now.saturating_sub(t) >= retention_ms,
814 None => true,
815 };
816 if !expired {
817 continue;
818 }
819 if let Err(e) = self.engine.store.delete_session(&session.id) {
820 tracing::warn!("purge retired session {}: {e}", session.id);
821 continue;
822 }
823 self.engine.emit(Event::SessionDone(session.id));
824 }
825 }
826}
827
828fn summarize_checks(pr_id: PrId, checks: &[CheckRun]) -> CIStatus {
831 let total = checks.len() as u32;
832 let failing = checks.iter().filter(|c| {
833 c.conclusion.as_deref() == Some("failure")
834 || c.conclusion.as_deref() == Some("timed_out")
835 }).count() as u32;
836 let passing = checks.iter().filter(|c| {
837 c.conclusion.as_deref() == Some("success")
838 }).count() as u32;
839 let pending = total - failing - passing;
840 CIStatus { pr_id, total, failing, passing, pending }
841}
842
843fn derive_session_status(
844 current: &SessionStatus,
845 pr_status: &crate::github::PrStatus,
846 ci: &CIStatus,
847 has_changes_requested: bool,
848) -> SessionStatus {
849 if matches!(current, SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted) {
851 return current.clone();
852 }
853 if pr_status.merged {
854 return SessionStatus::Done;
855 }
856 if ci.failing > 0 {
857 return SessionStatus::CiFailed;
858 }
859 if has_changes_requested {
860 return SessionStatus::ReviewPending;
861 }
862 if pr_status.mergeable == Some(true) && ci.failing == 0 && ci.pending == 0 {
863 return SessionStatus::Mergeable;
864 }
865 SessionStatus::PrOpen
866}
867
868#[cfg(test)]
869mod tests {
870 use super::*;
871 use crate::types::SessionStatus;
872
873 #[test]
874 fn summarize_checks_counts_failures() {
875 let checks = vec![
876 CheckRun { name: "lint".into(), status: "completed".into(), conclusion: Some("success".into()) },
877 CheckRun { name: "test".into(), status: "completed".into(), conclusion: Some("failure".into()) },
878 CheckRun { name: "build".into(), status: "in_progress".into(), conclusion: None },
879 ];
880 let ci = summarize_checks(1, &checks);
881 assert_eq!(ci.total, 3);
882 assert_eq!(ci.passing, 1);
883 assert_eq!(ci.failing, 1);
884 assert_eq!(ci.pending, 1);
885 }
886
887 #[test]
888 fn derive_status_merged_becomes_done() {
889 let pr = crate::github::PrStatus {
890 merged: true, state: "closed".into(), mergeable: None,
891 title: "t".into(), number: 1, head_sha: String::new(),
892 };
893 let ci = CIStatus { pr_id: 1, total: 0, failing: 0, passing: 0, pending: 0 };
894 let s = derive_session_status(&SessionStatus::PrOpen, &pr, &ci, false);
895 assert!(matches!(s, SessionStatus::Done));
896 }
897
898 #[test]
899 fn derive_status_ci_failure_overrides_open() {
900 let pr = crate::github::PrStatus {
901 merged: false, state: "open".into(), mergeable: Some(true),
902 title: "t".into(), number: 1, head_sha: String::new(),
903 };
904 let ci = CIStatus { pr_id: 1, total: 3, failing: 1, passing: 2, pending: 0 };
905 let s = derive_session_status(&SessionStatus::PrOpen, &pr, &ci, false);
906 assert!(matches!(s, SessionStatus::CiFailed));
907 }
908
909 #[test]
910 fn derive_status_all_green_becomes_mergeable() {
911 let pr = crate::github::PrStatus {
912 merged: false, state: "open".into(), mergeable: Some(true),
913 title: "t".into(), number: 1, head_sha: String::new(),
914 };
915 let ci = CIStatus { pr_id: 1, total: 3, failing: 0, passing: 3, pending: 0 };
916 let s = derive_session_status(&SessionStatus::PrOpen, &pr, &ci, false);
917 assert!(matches!(s, SessionStatus::Mergeable));
918 }
919
920 #[test]
921 fn derive_status_preserves_done() {
922 let pr = crate::github::PrStatus {
923 merged: false, state: "open".into(), mergeable: Some(true),
924 title: "t".into(), number: 1, head_sha: String::new(),
925 };
926 let ci = CIStatus { pr_id: 1, total: 0, failing: 0, passing: 0, pending: 0 };
927 let s = derive_session_status(&SessionStatus::Done, &pr, &ci, false);
928 assert!(matches!(s, SessionStatus::Done));
929 }
930
931 #[test]
932 fn derive_status_preserves_terminated() {
933 let pr = crate::github::PrStatus {
934 merged: true, state: "closed".into(), mergeable: None, title: "t".into(), number: 1, head_sha: String::new(),
936 };
937 let ci = CIStatus { pr_id: 1, total: 0, failing: 0, passing: 0, pending: 0 };
938 let s = derive_session_status(&SessionStatus::Terminated, &pr, &ci, false);
939 assert!(matches!(s, SessionStatus::Terminated)); }
941
942 #[test]
943 fn derive_status_changes_requested_becomes_review_pending() {
944 let pr = crate::github::PrStatus {
945 merged: false, state: "open".into(), mergeable: Some(true),
946 title: "t".into(), number: 1, head_sha: String::new(),
947 };
948 let ci = CIStatus { pr_id: 1, total: 3, failing: 0, passing: 3, pending: 0 };
949 let s = derive_session_status(&SessionStatus::PrOpen, &pr, &ci, true);
950 assert!(matches!(s, SessionStatus::ReviewPending));
951 }
952
953 fn test_session(id: &str, workspace: &str) -> crate::types::Session {
954 crate::types::Session {
955 id: id.into(), orchestrator_id: None, name: id.into(),
956 repo: String::new(), status: SessionStatus::Working,
957 agent_type: "claude-code".into(), cost_usd: 0.0, started_at: 0,
958 pr_number: None, pr_id: None,
959 workspace_path: Some(workspace.into()), pid: None,
960 model: None, context_tokens: None, catalogue_path: None,
961 context_used_pct: None, context_total_tokens: None, context_window_size: None,
962 claude_session_id: None,
963 terminal_at: None,
964 }
965 }
966
967 #[tokio::test]
968 async fn poll_pids_leaves_interrupted_sessions_alone() {
969 use crate::store::Store;
970
971 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
972 let mut s = test_session("interrupted-1", "/ws");
973 s.status = SessionStatus::Interrupted;
974 s.pid = Some(999_999); store.upsert_session(&s).unwrap();
976 let engine = Engine::new(store.clone());
977 let poller = Poller::new(engine);
978
979 poller.poll_pids().await;
980
981 let after = store.get_session("interrupted-1").unwrap().unwrap();
982 assert!(
983 matches!(after.status, SessionStatus::Interrupted),
984 "poll_pids must not re-terminate an Interrupted session just because its stale pid is dead",
985 );
986 }
987
988 #[allow(clippy::await_holding_lock)]
1001 #[tokio::test]
1002 async fn poll_usage_ingests_transcript_into_store_and_emits_update() {
1003 use crate::{lifecycle::usage::{claude_project_slug, ENV_TEST_GUARD}, store::Store};
1004 use std::io::Write;
1005
1006 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1007 let projects_dir = tempfile::tempdir().unwrap();
1008 let workspace = "/tmp/poller-usage-probe-workspace";
1009 let project_dir = projects_dir.path().join(claude_project_slug(workspace));
1010 std::fs::create_dir_all(&project_dir).unwrap();
1011 let mut f = std::fs::File::create(project_dir.join("s.jsonl")).unwrap();
1012 writeln!(
1013 f,
1014 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}}}}}}"#
1015 ).unwrap();
1016 drop(f);
1017
1018 let prior = std::env::var("NINOX_CLAUDE_PROJECTS_DIR").ok();
1019 std::env::set_var("NINOX_CLAUDE_PROJECTS_DIR", projects_dir.path());
1020
1021 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1022 store.upsert_session(&test_session("s1", workspace)).unwrap();
1023 let engine = Engine::new(store.clone());
1024 let mut rx = engine.subscribe();
1025 let poller = Poller::new(engine);
1026
1027 poller.poll_usage().await;
1028
1029 match prior {
1030 Some(v) => std::env::set_var("NINOX_CLAUDE_PROJECTS_DIR", v),
1031 None => std::env::remove_var("NINOX_CLAUDE_PROJECTS_DIR"),
1032 }
1033
1034 let updated = store.get_session("s1").unwrap().unwrap();
1035 assert!(updated.cost_usd > 0.0, "cost_usd should be ingested, not 0.0000");
1036 assert_eq!(updated.context_tokens, Some(2 + 500 + 45000));
1037 assert_eq!(updated.model.as_deref(), Some("claude-fable-5"));
1038
1039 let evt = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
1040 .await
1041 .expect("SessionUpdated should be emitted")
1042 .unwrap();
1043 assert!(matches!(evt, Event::SessionUpdated(s) if s.id == "s1" && s.cost_usd > 0.0));
1044 }
1045
1046 #[tokio::test]
1052 async fn poll_context_updates_emits_only_for_changed_sessions() {
1053 use crate::store::Store;
1054
1055 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1056 let mut s1 = test_session("s1", "/ws1");
1057 let s2 = test_session("s2", "/ws2");
1058 store.upsert_session(&s1).unwrap();
1059 store.upsert_session(&s2).unwrap();
1060 let engine = Engine::new(store.clone());
1061 let mut rx = engine.subscribe();
1062 let poller = Poller::new(engine);
1063
1064 poller.poll_context_updates().await;
1068 let baseline_events = drain_events(&mut rx);
1069 assert!(baseline_events.is_empty(), "no prior cached state means no change to report");
1070
1071 s1.context_used_pct = Some(42.0);
1073 s1.cost_usd = 3.5;
1074 store.upsert_session(&s1).unwrap();
1075
1076 poller.poll_context_updates().await;
1077 let events = drain_events(&mut rx);
1078 assert_eq!(events.len(), 1, "only the changed session should emit");
1079 assert!(matches!(
1080 &events[0],
1081 Event::SessionUpdated(s) if s.id == "s1" && s.context_used_pct == Some(42.0) && s.cost_usd == 3.5
1082 ));
1083
1084 poller.poll_context_updates().await;
1086 assert!(drain_events(&mut rx).is_empty());
1087 }
1088
1089 fn drain_events(rx: &mut tokio::sync::broadcast::Receiver<Event>) -> Vec<Event> {
1091 let mut events = Vec::new();
1092 while let Ok(e) = rx.try_recv() {
1093 events.push(e);
1094 }
1095 events
1096 }
1097
1098 #[tokio::test]
1102 async fn metadata_sync_adopts_first_pr_and_flags_every_extra_once() {
1103 use crate::store::Store;
1104
1105 let sessions_dir = tempfile::tempdir().unwrap();
1106 let meta = serde_json::json!({
1107 "agentReportedPrNumber": "44",
1108 "agentReportedPrUrl": "https://github.com/org/repo/pull/44",
1109 "agentReportedPrs": [
1110 {"number": "42", "url": "https://github.com/org/repo/pull/42"},
1111 {"number": "43", "url": "https://github.com/org/repo/pull/43"},
1112 {"number": "44", "url": "https://github.com/org/repo/pull/44"},
1113 ],
1114 });
1115 std::fs::write(
1116 sessions_dir.path().join("s1.json"),
1117 serde_json::to_string(&meta).unwrap(),
1118 ).unwrap();
1119
1120 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1121 store.upsert_session(&test_session("s1", "/ws")).unwrap();
1122 let engine = Engine::new(store.clone());
1123 let mut rx = engine.subscribe();
1124 let poller = Poller::new(engine);
1125
1126 poller.sync_sessions_metadata(sessions_dir.path()).await;
1127
1128 let session = store.get_session("s1").unwrap().unwrap();
1129 assert_eq!(session.pr_number, Some(42), "first PR is the canonical one");
1130 assert!(matches!(session.status, SessionStatus::PrOpen));
1131 assert!(store.get_pr(43).unwrap().is_some(), "extra PR #43 recorded");
1132 assert!(store.get_pr(44).unwrap().is_some(), "extra PR #44 recorded");
1133 assert_eq!(
1134 store.get_pr(43).unwrap().unwrap().url,
1135 "https://github.com/org/repo/pull/43",
1136 );
1137
1138 let events = drain_events(&mut rx);
1139 let extra_notifs: Vec<_> = events.iter().filter(|e| matches!(
1140 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::ExtraPr
1141 )).collect();
1142 assert_eq!(extra_notifs.len(), 2, "one ExtraPr notification per extra PR");
1143
1144 poller.sync_sessions_metadata(sessions_dir.path()).await;
1146 let events = drain_events(&mut rx);
1147 assert!(
1148 !events.iter().any(|e| matches!(e, Event::Notification(_))),
1149 "extra PRs must not be re-notified on every tick",
1150 );
1151 }
1152
1153 #[tokio::test]
1156 async fn metadata_sync_single_pr_has_no_extra_notifications() {
1157 use crate::store::Store;
1158
1159 let sessions_dir = tempfile::tempdir().unwrap();
1160 let meta = serde_json::json!({
1161 "agentReportedPrNumber": "5",
1162 "agentReportedPrUrl": "https://github.com/org/repo/pull/5",
1163 });
1164 std::fs::write(
1165 sessions_dir.path().join("s1.json"),
1166 serde_json::to_string(&meta).unwrap(),
1167 ).unwrap();
1168
1169 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1170 store.upsert_session(&test_session("s1", "/ws")).unwrap();
1171 let engine = Engine::new(store.clone());
1172 let mut rx = engine.subscribe();
1173 let poller = Poller::new(engine);
1174
1175 poller.sync_sessions_metadata(sessions_dir.path()).await;
1176
1177 let session = store.get_session("s1").unwrap().unwrap();
1178 assert_eq!(session.pr_number, Some(5));
1179 assert!(matches!(session.status, SessionStatus::PrOpen));
1180 let events = drain_events(&mut rx);
1181 assert!(!events.iter().any(|e| matches!(e, Event::Notification(_))));
1182 }
1183
1184 #[tokio::test]
1187 async fn metadata_sync_delivers_work_requests_exactly_once() {
1188 use crate::store::Store;
1189
1190 let sessions_dir = tempfile::tempdir().unwrap();
1191 hooks::append_work_request(sessions_dir.path(), "s1", "Migrate the config loader").unwrap();
1192
1193 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1194 store.upsert_session(&test_session("s1", "/ws")).unwrap();
1195 let engine = Engine::new(store.clone());
1196 let mut rx = engine.subscribe();
1197 let poller = Poller::new(engine);
1198
1199 poller.sync_sessions_metadata(sessions_dir.path()).await;
1200
1201 let events = drain_events(&mut rx);
1202 let notif = events.iter().find_map(|e| match e {
1203 Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkRequested => Some(n),
1204 _ => None,
1205 }).expect("WorkRequested notification emitted");
1206 assert!(notif.body.contains("Migrate the config loader"));
1207 assert_eq!(notif.session_id.as_deref(), Some("s1"));
1208
1209 assert!(
1210 hooks::read_pending_work_requests(sessions_dir.path(), "s1").unwrap().is_empty(),
1211 "delivered requests must leave the pending set",
1212 );
1213
1214 poller.sync_sessions_metadata(sessions_dir.path()).await;
1215 let events = drain_events(&mut rx);
1216 assert!(
1217 !events.iter().any(|e| matches!(e, Event::Notification(_))),
1218 "delivered work requests must not fire again",
1219 );
1220 }
1221
1222 #[tokio::test]
1225 async fn metadata_sync_delivers_work_requests_from_terminated_sessions() {
1226 use crate::store::Store;
1227
1228 let sessions_dir = tempfile::tempdir().unwrap();
1229 hooks::append_work_request(sessions_dir.path(), "s1", "Follow-up refactor").unwrap();
1230
1231 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1232 let mut session = test_session("s1", "/ws");
1233 session.status = SessionStatus::Terminated;
1234 store.upsert_session(&session).unwrap();
1235 let engine = Engine::new(store.clone());
1236 let mut rx = engine.subscribe();
1237 let poller = Poller::new(engine);
1238
1239 poller.sync_sessions_metadata(sessions_dir.path()).await;
1240
1241 let events = drain_events(&mut rx);
1242 assert!(
1243 events.iter().any(|e| matches!(
1244 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkRequested
1245 )),
1246 "work requests outlive their session",
1247 );
1248 }
1249
1250 #[tokio::test]
1254 async fn extra_pr_ledger_row_backfills_after_notification_without_renotifying() {
1255 use crate::store::Store;
1256
1257 let sessions_dir = tempfile::tempdir().unwrap();
1258 let meta = serde_json::json!({
1259 "agentReportedPrs": [
1260 {"number": "7", "url": "https://github.com/org/repo/pull/7"},
1261 {"number": "9", "url": "https://github.com/org/repo/pull/9"},
1262 ],
1263 });
1264 std::fs::write(
1265 sessions_dir.path().join("s1.json"),
1266 serde_json::to_string(&meta).unwrap(),
1267 ).unwrap();
1268 hooks::mark_extra_prs_notified(sessions_dir.path(), "s1", &[9]).unwrap();
1270
1271 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1272 store.upsert_session(&test_session("s1", "/ws")).unwrap();
1273 let engine = Engine::new(store.clone());
1274 let mut rx = engine.subscribe();
1275 let poller = Poller::new(engine);
1276
1277 poller.sync_sessions_metadata(sessions_dir.path()).await;
1278
1279 assert!(
1280 store.get_pr(9).unwrap().is_some(),
1281 "already-notified extra PR must still get its ledger row backfilled",
1282 );
1283 let events = drain_events(&mut rx);
1284 assert!(
1285 !events.iter().any(|e| matches!(
1286 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::ExtraPr
1287 )),
1288 "backfilling the row must not re-notify",
1289 );
1290 }
1291
1292 #[tokio::test]
1296 async fn extra_pr_detection_survives_cross_repo_pr_number_collision() {
1297 use crate::store::Store;
1298
1299 let sessions_dir = tempfile::tempdir().unwrap();
1300 let meta = serde_json::json!({
1301 "agentReportedPrs": [
1302 {"number": "7", "url": "https://github.com/org/repo-a/pull/7"},
1303 {"number": "9", "url": "https://github.com/org/repo-a/pull/9"},
1304 ],
1305 });
1306 std::fs::write(
1307 sessions_dir.path().join("s1.json"),
1308 serde_json::to_string(&meta).unwrap(),
1309 ).unwrap();
1310
1311 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1312 store.upsert_session(&test_session("s1", "/ws")).unwrap();
1313 let other = PR {
1315 id: 9, number: 9, title: "other repo's PR".into(),
1316 url: "https://github.com/org/repo-b/pull/9".into(),
1317 body: String::new(), session_id: "other".into(),
1318 };
1319 store.upsert_pr(&other).unwrap();
1320
1321 let engine = Engine::new(store.clone());
1322 let mut rx = engine.subscribe();
1323 let poller = Poller::new(engine);
1324
1325 poller.sync_sessions_metadata(sessions_dir.path()).await;
1326
1327 let events = drain_events(&mut rx);
1328 assert!(
1329 events.iter().any(|e| matches!(
1330 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::ExtraPr
1331 )),
1332 "the collision must not suppress the extra-PR alert",
1333 );
1334 let row = store.get_pr(9).unwrap().unwrap();
1335 assert_eq!(row.session_id, "other", "the other repo's row must not be stolen");
1336 assert_eq!(row.url, "https://github.com/org/repo-b/pull/9");
1337
1338 poller.sync_sessions_metadata(sessions_dir.path()).await;
1339 let events = drain_events(&mut rx);
1340 assert!(
1341 !events.iter().any(|e| matches!(e, Event::Notification(_))),
1342 "dedup must hold across ticks even without a prs row of our own",
1343 );
1344 }
1345
1346 #[tokio::test]
1347 async fn poll_usage_leaves_sessions_without_workspace_or_usage_untouched() {
1348 use crate::store::Store;
1349
1350 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1351 let mut no_ws = test_session("no-ws", "/does/not/matter");
1352 no_ws.workspace_path = None;
1353 store.upsert_session(&no_ws).unwrap();
1354 let engine = Engine::new(store.clone());
1355 let poller = Poller::new(engine);
1356
1357 poller.poll_usage().await;
1358
1359 let unchanged = store.get_session("no-ws").unwrap().unwrap();
1360 assert_eq!(unchanged.cost_usd, 0.0);
1361 assert_eq!(unchanged.context_tokens, None);
1362 }
1363
1364 #[derive(Default)]
1369 struct FakeGithub {
1370 pr_status_ok: std::sync::Mutex<HashMap<(String, String, u64), crate::github::PrStatus>>,
1374 branch_matches: std::sync::Mutex<HashMap<(String, String, String), crate::github::PrRef>>,
1375 calls: std::sync::Mutex<Vec<(String, String, u64)>>,
1377 }
1378
1379 #[async_trait::async_trait]
1380 impl crate::github::GithubApi for FakeGithub {
1381 async fn get_pr_status(&self, owner: &str, repo: &str, pr_number: u64) -> anyhow::Result<crate::github::PrStatus> {
1382 self.calls.lock().unwrap().push((owner.to_string(), repo.to_string(), pr_number));
1383 self.pr_status_ok.lock().unwrap()
1384 .get(&(owner.to_string(), repo.to_string(), pr_number))
1385 .cloned()
1386 .ok_or_else(|| anyhow::anyhow!("404 for {owner}/{repo}#{pr_number}"))
1387 }
1388 async fn get_ci_checks(&self, _owner: &str, _repo: &str, _head_sha: &str) -> anyhow::Result<Vec<CheckRun>> {
1389 Ok(vec![])
1390 }
1391 async fn get_review_threads(&self, _owner: &str, _repo: &str, _pr_number: u64) -> anyhow::Result<Vec<crate::github::ReviewThread>> {
1392 Ok(vec![])
1393 }
1394 async fn find_open_pr_for_branch(&self, owner: &str, repo: &str, branch: &str) -> anyhow::Result<Option<crate::github::PrRef>> {
1395 Ok(self.branch_matches.lock().unwrap()
1396 .get(&(owner.to_string(), repo.to_string(), branch.to_string()))
1397 .cloned())
1398 }
1399 }
1400
1401 fn init_git_repo(branch: &str, remotes: &[(&str, &str)]) -> tempfile::TempDir {
1402 let dir = tempfile::tempdir().unwrap();
1403 let workspace = dir.path().to_string_lossy().to_string();
1404 let run = |args: &[&str]| {
1405 let status = std::process::Command::new("git")
1406 .args(["-C", &workspace]).args(args).status().unwrap();
1407 assert!(status.success(), "git {args:?} failed");
1408 };
1409 run(&["init", "-q"]);
1410 run(&["config", "user.email", "test@example.com"]);
1411 run(&["config", "user.name", "Test"]);
1412 run(&["commit", "--allow-empty", "-q", "-m", "init"]);
1413 run(&["checkout", "-q", "-b", branch]);
1414 for (name, url) in remotes {
1415 run(&["remote", "add", name, url]);
1416 }
1417 dir
1418 }
1419
1420 fn github_engine(store: std::sync::Arc<crate::store::Store>, gh: std::sync::Arc<FakeGithub>) -> std::sync::Arc<Engine> {
1421 Engine::new_with_github_api(store, gh as std::sync::Arc<dyn crate::github::GithubApi>)
1422 }
1423
1424 #[tokio::test]
1429 async fn poll_pr_reconciliation_adopts_pr_found_without_wrapper_metadata() {
1430 use crate::store::Store;
1431
1432 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
1433 let workspace = repo_dir.path().to_string_lossy().to_string();
1434
1435 let fake = std::sync::Arc::new(FakeGithub::default());
1436 fake.branch_matches.lock().unwrap().insert(
1437 ("Owner".to_string(), "repo".to_string(), "worker-branch".to_string()),
1438 crate::github::PrRef { number: 77, url: "https://github.com/Owner/repo/pull/77".into() },
1439 );
1440
1441 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1442 store.upsert_session(&test_session("s1", &workspace)).unwrap();
1443 let engine = github_engine(store.clone(), fake);
1444 let poller = Poller::new(engine);
1445
1446 poller.poll_pr_reconciliation().await;
1447
1448 let session = store.get_session("s1").unwrap().unwrap();
1449 assert_eq!(session.pr_number, Some(77), "no wrapper metadata was ever written — reconciliation must still find it");
1450 assert!(matches!(session.status, SessionStatus::PrOpen));
1451 assert_eq!(session.repo, "Owner/repo");
1452 }
1453
1454 #[tokio::test]
1455 async fn poll_pr_reconciliation_leaves_sessions_with_no_matching_pr_untouched() {
1456 use crate::store::Store;
1457
1458 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
1459 let workspace = repo_dir.path().to_string_lossy().to_string();
1460
1461 let fake = std::sync::Arc::new(FakeGithub::default()); let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1464 store.upsert_session(&test_session("s1", &workspace)).unwrap();
1465 let engine = github_engine(store.clone(), fake);
1466 let poller = Poller::new(engine);
1467
1468 poller.poll_pr_reconciliation().await;
1469
1470 let session = store.get_session("s1").unwrap().unwrap();
1471 assert_eq!(session.pr_number, None);
1472 }
1473
1474 #[tokio::test]
1484 async fn poll_github_falls_back_to_other_remote_when_recorded_repo_404s() {
1485 use crate::store::Store;
1486
1487 let repo_dir = init_git_repo("worker-branch", &[
1488 ("origin", "https://github.com/OwnerA/repoA.git"),
1489 ("mirror", "https://github.com/OwnerB/repoB.git"),
1490 ]);
1491 let workspace = repo_dir.path().to_string_lossy().to_string();
1492
1493 let fake = std::sync::Arc::new(FakeGithub::default());
1494 fake.branch_matches.lock().unwrap().insert(
1495 ("OwnerB".to_string(), "repoB".to_string(), "worker-branch".to_string()),
1496 crate::github::PrRef { number: 99, url: "https://github.com/OwnerB/repoB/pull/99".into() },
1497 );
1498 fake.pr_status_ok.lock().unwrap().insert(
1499 ("OwnerB".to_string(), "repoB".to_string(), 99),
1500 crate::github::PrStatus {
1501 merged: false, state: "open".into(), mergeable: Some(true),
1502 title: "t".into(), number: 99, head_sha: "abc".into(),
1503 },
1504 );
1505 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1508 let mut session = test_session("s1", &workspace);
1509 session.repo = "OwnerA/repoA".into();
1510 session.pr_number = Some(50);
1511 store.upsert_session(&session).unwrap();
1512
1513 let engine = github_engine(store.clone(), fake.clone());
1514 let poller = Poller::new(engine);
1515
1516 poller.poll_github().await;
1517
1518 let updated = store.get_session("s1").unwrap().unwrap();
1519 assert_eq!(updated.repo, "OwnerB/repoB", "session.repo must self-heal to the remote that actually has the PR");
1520 assert_eq!(updated.pr_number, Some(99), "must adopt the mirror's own PR number, not reuse the tracked repo's number");
1521
1522 let calls = fake.calls.lock().unwrap().clone();
1523 assert_eq!(
1524 calls,
1525 vec![("OwnerA".to_string(), "repoA".to_string(), 50), ("OwnerB".to_string(), "repoB".to_string(), 99)],
1526 "the repo on record must be tried first (by its own number), the mirror only as a branch-matched fallback",
1527 );
1528 }
1529
1530 #[tokio::test]
1539 async fn poll_github_does_not_adopt_an_unrelated_pr_that_shares_the_tracked_number() {
1540 use crate::store::Store;
1541
1542 let repo_dir = init_git_repo("worker-branch", &[
1543 ("origin", "https://github.com/OwnerA/repoA.git"),
1544 ("mirror", "https://github.com/OwnerB/repoB.git"),
1545 ]);
1546 let workspace = repo_dir.path().to_string_lossy().to_string();
1547
1548 let fake = std::sync::Arc::new(FakeGithub::default());
1549 fake.pr_status_ok.lock().unwrap().insert(
1552 ("OwnerB".to_string(), "repoB".to_string(), 50),
1553 crate::github::PrStatus {
1554 merged: false, state: "open".into(), mergeable: Some(true),
1555 title: "someone else's unrelated PR".into(), number: 50, head_sha: "zzz".into(),
1556 },
1557 );
1558 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1561 let mut session = test_session("s1", &workspace);
1562 session.repo = "OwnerA/repoA".into();
1563 session.pr_number = Some(50);
1564 store.upsert_session(&session).unwrap();
1565
1566 let engine = github_engine(store.clone(), fake.clone());
1567 let poller = Poller::new(engine);
1568
1569 poller.poll_github().await;
1570
1571 let updated = store.get_session("s1").unwrap().unwrap();
1572 assert_eq!(updated.repo, "OwnerA/repoA", "must not adopt the mirror just because it happens to have a same-numbered PR");
1573 assert_eq!(updated.pr_number, Some(50));
1574
1575 let calls = fake.calls.lock().unwrap().clone();
1576 assert!(
1577 !calls.contains(&("OwnerB".to_string(), "repoB".to_string(), 50)),
1578 "must never retry the tracked number against another repo's get_pr_status: {calls:?}",
1579 );
1580 }
1581
1582 #[tokio::test]
1586 async fn poll_github_notifies_once_when_every_remote_fails_and_rearms_after_recovery() {
1587 use crate::store::Store;
1588
1589 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/OwnerA/repoA.git")]);
1590 let workspace = repo_dir.path().to_string_lossy().to_string();
1591
1592 let fake = std::sync::Arc::new(FakeGithub::default()); let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1595 let mut session = test_session("s1", &workspace);
1596 session.repo = "OwnerA/repoA".into();
1597 session.pr_number = Some(50);
1598 store.upsert_session(&session).unwrap();
1599
1600 let engine = github_engine(store.clone(), fake.clone());
1601 let mut rx = engine.subscribe();
1602 let poller = Poller::new(engine);
1603
1604 poller.poll_github().await;
1605 let events = drain_events(&mut rx);
1606 let failures = |evs: &[Event]| evs.iter().filter(|e| matches!(
1607 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::GithubLookupFailed
1608 )).count();
1609 assert_eq!(failures(&events), 1, "first failure must notify");
1610
1611 poller.poll_github().await;
1612 let events = drain_events(&mut rx);
1613 assert_eq!(failures(&events), 0, "repeated failure must not re-notify");
1614
1615 fake.pr_status_ok.lock().unwrap().insert(
1617 ("OwnerA".to_string(), "repoA".to_string(), 50),
1618 crate::github::PrStatus {
1619 merged: false, state: "open".into(), mergeable: Some(true),
1620 title: "t".into(), number: 50, head_sha: "abc".into(),
1621 },
1622 );
1623 poller.poll_github().await;
1624
1625 fake.pr_status_ok.lock().unwrap().clear();
1627 poller.poll_github().await;
1628 let events = drain_events(&mut rx);
1629 assert_eq!(failures(&events), 1, "must notify again after recovering and failing anew");
1630 }
1631
1632 use std::{future::Future, pin::Pin};
1635
1636 struct FakeHarvestRunner {
1640 calls: tokio::sync::mpsc::UnboundedSender<(String, PathBuf, PathBuf)>,
1641 outcome: Result<(), String>,
1642 }
1643
1644 impl HarvestRunner for FakeHarvestRunner {
1645 fn run(
1646 &self,
1647 prompt: String,
1648 workspace: PathBuf,
1649 brain_path: PathBuf,
1650 ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>> {
1651 let _ = self.calls.send((prompt, workspace, brain_path));
1652 let outcome = self.outcome.clone();
1653 Box::pin(async move {
1654 outcome.map_err(|e| anyhow::anyhow!(e))
1655 })
1656 }
1657 }
1658
1659 fn init_diff_repo(feature_branch: &str, extra_file: Option<(&str, &str)>) -> std::path::PathBuf {
1664 let dir = tempfile::tempdir().unwrap().keep();
1665 let run = |args: &[&str]| {
1666 std::process::Command::new("git")
1667 .args(["-C", dir.to_str().unwrap()])
1668 .args(args)
1669 .output()
1670 .unwrap()
1671 };
1672 run(&["init", "-q", "-b", "main"]);
1673 run(&["config", "user.email", "test@example.com"]);
1674 run(&["config", "user.name", "Test"]);
1675 std::fs::write(dir.join("README.md"), "x").unwrap();
1676 run(&["add", "."]);
1677 run(&["commit", "-q", "-m", "init"]);
1678 run(&["checkout", "-q", "-b", feature_branch]);
1679 if let Some((name, contents)) = extra_file {
1680 std::fs::write(dir.join(name), contents).unwrap();
1681 run(&["add", name]);
1682 run(&["commit", "-q", "-m", "feature work"]);
1683 }
1684 dir
1685 }
1686
1687 fn nonexistent_config_path() -> std::path::PathBuf {
1691 tempfile::tempdir().unwrap().keep().join("nonexistent-ninox-config.toml")
1692 }
1693
1694 #[allow(clippy::await_holding_lock)]
1699 #[tokio::test]
1700 async fn metadata_sync_triggers_brain_harvest_exactly_once_on_pr_detection() {
1701 use crate::{config::ENV_TEST_GUARD, store::Store};
1702
1703 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1704 let prior = std::env::var("NINOX_CONFIG").ok();
1705 std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
1706
1707 let repo = init_diff_repo("feature-1", Some(("src.rs", "fn main() {}\n")));
1708 let workspace = repo.to_str().unwrap().to_string();
1709
1710 let sessions_dir = tempfile::tempdir().unwrap();
1711 let meta = serde_json::json!({"agentReportedPrNumber": "9"});
1712 std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
1713
1714 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1715 store.upsert_session(&test_session("s1", &workspace)).unwrap();
1716 let engine = Engine::new(store.clone());
1717
1718 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1719 let runner = Arc::new(FakeHarvestRunner { calls: tx, outcome: Ok(()) });
1720 let poller = Poller::new_with_harvest_runner(engine, runner);
1721
1722 poller.sync_sessions_metadata(sessions_dir.path()).await;
1723
1724 let (prompt, ..) = tokio::time::timeout(Duration::from_secs(2), rx.recv())
1725 .await
1726 .expect("harvest should be attempted")
1727 .expect("channel should not be closed");
1728 assert!(prompt.contains("src.rs") && prompt.contains("fn main"), "prompt must include the diff");
1729
1730 poller.sync_sessions_metadata(sessions_dir.path()).await;
1733 assert!(rx.try_recv().is_err(), "harvest must fire exactly once per session");
1734
1735 match prior {
1736 Some(v) => std::env::set_var("NINOX_CONFIG", v),
1737 None => std::env::remove_var("NINOX_CONFIG"),
1738 }
1739 }
1740
1741 #[allow(clippy::await_holding_lock)]
1746 #[tokio::test]
1747 async fn metadata_sync_brain_harvest_prefers_session_catalogue_path_over_default() {
1748 use crate::{config::ENV_TEST_GUARD, store::Store};
1749
1750 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1751 let prior = std::env::var("NINOX_CONFIG").ok();
1752 std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
1753
1754 let repo = init_diff_repo("feature-catalogue", Some(("src.rs", "fn main() {}\n")));
1755 let workspace = repo.to_str().unwrap().to_string();
1756
1757 let sessions_dir = tempfile::tempdir().unwrap();
1758 let meta = serde_json::json!({"agentReportedPrNumber": "13"});
1759 std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
1760
1761 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1762 let mut session = test_session("s1", &workspace);
1763 session.catalogue_path = Some("/custom/brain-catalogue".to_string());
1764 store.upsert_session(&session).unwrap();
1765 let engine = Engine::new(store.clone());
1766
1767 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1768 let runner = Arc::new(FakeHarvestRunner { calls: tx, outcome: Ok(()) });
1769 let poller = Poller::new_with_harvest_runner(engine, runner);
1770
1771 poller.sync_sessions_metadata(sessions_dir.path()).await;
1772
1773 let (_, _, brain_path) = tokio::time::timeout(Duration::from_secs(2), rx.recv())
1774 .await
1775 .expect("harvest should be attempted")
1776 .expect("channel should not be closed");
1777 assert_eq!(
1778 brain_path, PathBuf::from("/custom/brain-catalogue"),
1779 "harvest must target the session's own catalogue, not the global default",
1780 );
1781
1782 match prior {
1783 Some(v) => std::env::set_var("NINOX_CONFIG", v),
1784 None => std::env::remove_var("NINOX_CONFIG"),
1785 }
1786 }
1787
1788 #[allow(clippy::await_holding_lock)]
1791 #[tokio::test]
1792 async fn metadata_sync_skips_brain_harvest_when_disabled() {
1793 use crate::{config::ENV_TEST_GUARD, store::Store};
1794
1795 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1796 let config_dir = tempfile::tempdir().unwrap();
1797 let config_path = config_dir.path().join("config.toml");
1798 std::fs::write(&config_path, "port = 8080\nfont_size = 13.0\n\n[brain_harvest]\nenabled = false\n").unwrap();
1799 let prior = std::env::var("NINOX_CONFIG").ok();
1800 std::env::set_var("NINOX_CONFIG", &config_path);
1801
1802 let repo = init_diff_repo("feature-2", Some(("src.rs", "fn main() {}\n")));
1803 let workspace = repo.to_str().unwrap().to_string();
1804
1805 let sessions_dir = tempfile::tempdir().unwrap();
1806 let meta = serde_json::json!({"agentReportedPrNumber": "10"});
1807 std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
1808
1809 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1810 store.upsert_session(&test_session("s1", &workspace)).unwrap();
1811 let engine = Engine::new(store.clone());
1812
1813 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1814 let runner = Arc::new(FakeHarvestRunner { calls: tx, outcome: Ok(()) });
1815 let poller = Poller::new_with_harvest_runner(engine, runner);
1816
1817 poller.sync_sessions_metadata(sessions_dir.path()).await;
1818
1819 assert!(rx.try_recv().is_err(), "harvest must not fire when brain_harvest.enabled = false");
1820 let session = store.get_session("s1").unwrap().unwrap();
1821 assert_eq!(session.pr_number, Some(10), "PR detection must be unaffected by the disabled harvest");
1822 assert!(matches!(session.status, SessionStatus::PrOpen));
1823
1824 match prior {
1825 Some(v) => std::env::set_var("NINOX_CONFIG", v),
1826 None => std::env::remove_var("NINOX_CONFIG"),
1827 }
1828 }
1829
1830 #[allow(clippy::await_holding_lock)]
1834 #[tokio::test]
1835 async fn metadata_sync_skips_brain_harvest_for_trivial_diff() {
1836 use crate::{config::ENV_TEST_GUARD, store::Store};
1837
1838 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1839 let prior = std::env::var("NINOX_CONFIG").ok();
1840 std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
1841
1842 let repo = init_diff_repo("feature-3", None);
1844 let workspace = repo.to_str().unwrap().to_string();
1845
1846 let sessions_dir = tempfile::tempdir().unwrap();
1847 let meta = serde_json::json!({"agentReportedPrNumber": "11"});
1848 std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
1849
1850 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1851 store.upsert_session(&test_session("s1", &workspace)).unwrap();
1852 let engine = Engine::new(store.clone());
1853
1854 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1855 let runner = Arc::new(FakeHarvestRunner { calls: tx, outcome: Ok(()) });
1856 let poller = Poller::new_with_harvest_runner(engine, runner);
1857
1858 poller.sync_sessions_metadata(sessions_dir.path()).await;
1859
1860 assert!(rx.try_recv().is_err(), "harvest must not fire for an empty diff");
1861
1862 match prior {
1863 Some(v) => std::env::set_var("NINOX_CONFIG", v),
1864 None => std::env::remove_var("NINOX_CONFIG"),
1865 }
1866 }
1867
1868 #[allow(clippy::await_holding_lock)]
1872 #[tokio::test]
1873 async fn metadata_sync_survives_a_failing_brain_harvest() {
1874 use crate::{config::ENV_TEST_GUARD, store::Store};
1875
1876 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1877 let prior = std::env::var("NINOX_CONFIG").ok();
1878 std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
1879
1880 let repo = init_diff_repo("feature-4", Some(("src.rs", "fn main() {}\n")));
1881 let workspace = repo.to_str().unwrap().to_string();
1882
1883 let sessions_dir = tempfile::tempdir().unwrap();
1884 let meta = serde_json::json!({"agentReportedPrNumber": "12"});
1885 std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
1886
1887 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1888 store.upsert_session(&test_session("s1", &workspace)).unwrap();
1889 let engine = Engine::new(store.clone());
1890
1891 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1892 let runner = Arc::new(FakeHarvestRunner {
1893 calls: tx,
1894 outcome: Err("simulated claude -p failure".to_string()),
1895 });
1896 let poller = Poller::new_with_harvest_runner(engine, runner);
1897
1898 poller.sync_sessions_metadata(sessions_dir.path()).await;
1899
1900 let session = store.get_session("s1").unwrap().unwrap();
1901 assert_eq!(session.pr_number, Some(12), "PR detection must succeed regardless of harvest outcome");
1902 assert!(matches!(session.status, SessionStatus::PrOpen));
1903
1904 tokio::time::timeout(Duration::from_secs(2), rx.recv())
1905 .await
1906 .expect("the failing harvest must still be attempted")
1907 .expect("channel should not be closed");
1908
1909 match prior {
1910 Some(v) => std::env::set_var("NINOX_CONFIG", v),
1911 None => std::env::remove_var("NINOX_CONFIG"),
1912 }
1913 }
1914
1915 #[derive(Clone, Default)]
1918 struct CapturedLogs(Arc<std::sync::Mutex<Vec<String>>>);
1919
1920 impl CapturedLogs {
1921 fn contains(&self, needle: &str) -> bool {
1922 self.0.lock().unwrap().iter().any(|m| m.contains(needle))
1923 }
1924 }
1925
1926 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CapturedLogs {
1927 fn on_event(&self, event: &tracing::Event<'_>, _ctx: tracing_subscriber::layer::Context<'_, S>) {
1928 struct Visitor(String);
1929 impl tracing::field::Visit for Visitor {
1930 fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
1931 if field.name() == "message" {
1932 self.0 = format!("{value:?}");
1933 }
1934 }
1935 }
1936 let mut visitor = Visitor(String::new());
1937 event.record(&mut visitor);
1938 self.0.lock().unwrap().push(visitor.0);
1939 }
1940 }
1941
1942 struct PanickingHarvestRunner;
1946
1947 impl HarvestRunner for PanickingHarvestRunner {
1948 fn run(
1949 &self,
1950 _prompt: String,
1951 _workspace: PathBuf,
1952 _brain_path: PathBuf,
1953 ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>> {
1954 Box::pin(async move { panic!("simulated harvest panic") })
1955 }
1956 }
1957
1958 #[allow(clippy::await_holding_lock)]
1963 #[tokio::test]
1964 async fn metadata_sync_logs_a_warning_when_harvest_task_panics() {
1965 use crate::{config::ENV_TEST_GUARD, store::Store};
1966 use tracing_subscriber::{layer::SubscriberExt, Registry};
1967
1968 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1969 let prior = std::env::var("NINOX_CONFIG").ok();
1970 std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
1971
1972 let logs = CapturedLogs::default();
1973 let _log_guard = tracing::subscriber::set_default(Registry::default().with(logs.clone()));
1974
1975 let repo = init_diff_repo("feature-panic", Some(("src.rs", "fn main() {}\n")));
1976 let workspace = repo.to_str().unwrap().to_string();
1977
1978 let sessions_dir = tempfile::tempdir().unwrap();
1979 let meta = serde_json::json!({"agentReportedPrNumber": "20"});
1980 std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
1981
1982 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1983 store.upsert_session(&test_session("s1", &workspace)).unwrap();
1984 let engine = Engine::new(store.clone());
1985
1986 let poller = Poller::new_with_harvest_runner(engine, Arc::new(PanickingHarvestRunner));
1987 poller.sync_sessions_metadata(sessions_dir.path()).await;
1988
1989 let deadline = std::time::Instant::now() + Duration::from_secs(5);
1993 while !logs.contains("brain harvest task panicked") {
1994 assert!(std::time::Instant::now() < deadline, "timed out waiting for the panic to be logged");
1995 tokio::time::sleep(Duration::from_millis(20)).await;
1996 }
1997
1998 match prior {
1999 Some(v) => std::env::set_var("NINOX_CONFIG", v),
2000 None => std::env::remove_var("NINOX_CONFIG"),
2001 }
2002 }
2003
2004 struct OverlapDetectingHarvestRunner {
2009 calls: tokio::sync::mpsc::UnboundedSender<()>,
2010 active: Arc<std::sync::atomic::AtomicUsize>,
2011 overlapped: Arc<std::sync::atomic::AtomicBool>,
2012 }
2013
2014 impl HarvestRunner for OverlapDetectingHarvestRunner {
2015 fn run(
2016 &self,
2017 _prompt: String,
2018 _workspace: PathBuf,
2019 _brain_path: PathBuf,
2020 ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>> {
2021 let _ = self.calls.send(());
2022 let active = self.active.clone();
2023 let overlapped = self.overlapped.clone();
2024 Box::pin(async move {
2025 if active.fetch_add(1, std::sync::atomic::Ordering::SeqCst) > 0 {
2026 overlapped.store(true, std::sync::atomic::Ordering::SeqCst);
2027 }
2028 tokio::time::sleep(Duration::from_millis(50)).await;
2029 active.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
2030 Ok(())
2031 })
2032 }
2033 }
2034
2035 #[allow(clippy::await_holding_lock)]
2041 #[tokio::test]
2042 async fn concurrent_harvests_to_the_same_vault_do_not_overlap() {
2043 use crate::{config::ENV_TEST_GUARD, store::Store};
2044
2045 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2046 let prior = std::env::var("NINOX_CONFIG").ok();
2047 std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
2048
2049 let repo1 = init_diff_repo("feature-vault-1", Some(("a.rs", "fn a() {}\n")));
2050 let repo2 = init_diff_repo("feature-vault-2", Some(("b.rs", "fn b() {}\n")));
2051
2052 let sessions_dir = tempfile::tempdir().unwrap();
2053 for (id, pr) in [("s1", "30"), ("s2", "31")] {
2054 let meta = serde_json::json!({"agentReportedPrNumber": pr});
2055 std::fs::write(sessions_dir.path().join(format!("{id}.json")), serde_json::to_string(&meta).unwrap()).unwrap();
2056 }
2057
2058 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2059 store.upsert_session(&test_session("s1", repo1.to_str().unwrap())).unwrap();
2060 store.upsert_session(&test_session("s2", repo2.to_str().unwrap())).unwrap();
2061 let engine = Engine::new(store.clone());
2062
2063 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2064 let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2065 let overlapped = Arc::new(std::sync::atomic::AtomicBool::new(false));
2066 let runner = Arc::new(OverlapDetectingHarvestRunner {
2067 calls: tx, active: active.clone(), overlapped: overlapped.clone(),
2068 });
2069 let poller = Poller::new_with_harvest_runner(engine, runner);
2070
2071 poller.sync_sessions_metadata(sessions_dir.path()).await;
2072
2073 for _ in 0..2 {
2074 tokio::time::timeout(Duration::from_secs(2), rx.recv())
2075 .await
2076 .expect("both harvests should be attempted")
2077 .expect("channel should not be closed");
2078 }
2079 tokio::time::sleep(Duration::from_millis(150)).await;
2082
2083 assert!(
2084 !overlapped.load(std::sync::atomic::Ordering::SeqCst),
2085 "concurrent harvests to the same vault must not run HarvestRunner::run concurrently",
2086 );
2087
2088 match prior {
2089 Some(v) => std::env::set_var("NINOX_CONFIG", v),
2090 None => std::env::remove_var("NINOX_CONFIG"),
2091 }
2092 }
2093
2094 #[test]
2100 fn vault_lock_treats_equivalent_paths_as_the_same_vault() {
2101 use crate::store::Store;
2102
2103 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2104 let engine = Engine::new(store);
2105 let poller = Poller::new_with_harvest_runner(engine, Arc::new(ClaudeHarvestRunner));
2106
2107 let dir = tempfile::tempdir().unwrap();
2108 let canonical = dir.path().to_path_buf();
2109 let with_trailing_slash = PathBuf::from(format!("{}/", canonical.display()));
2110
2111 let lock_a = poller.vault_lock(&canonical);
2112 let lock_b = poller.vault_lock(&with_trailing_slash);
2113
2114 assert!(
2115 Arc::ptr_eq(&lock_a, &lock_b),
2116 "syntactically different paths to the same physical vault must share one lock",
2117 );
2118 }
2119 #[tokio::test]
2125 async fn merge_detection_fires_for_terminated_session() {
2126 use crate::store::Store;
2127
2128 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2129 let mut s = test_session("w1", "/ws");
2130 s.status = SessionStatus::Terminated;
2131 s.orchestrator_id = Some("orch1".into());
2132 s.pr_number = Some(42);
2133 store.upsert_session(&s).unwrap();
2134
2135 let engine = Engine::new(store.clone());
2136 let mut rx = engine.subscribe();
2137 let poller = Poller::new(engine);
2138
2139 let handled = poller.handle_merge_detection(&s, 42, true).await;
2140 assert!(handled, "merge detection must run for a Terminated session");
2141
2142 let after = store.get_session("w1").unwrap().unwrap();
2143 assert!(matches!(after.status, SessionStatus::Done), "merged session transitions to Done");
2144 assert!(after.terminal_at.is_some(), "Done via merge detection must stamp terminal_at");
2145
2146 let events = drain_events(&mut rx);
2147 let merged_notifs = events.iter().filter(|e| matches!(
2148 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkerDone
2149 )).count();
2150 assert_eq!(merged_notifs, 1, "exactly one WorkerDone notification for the merge");
2151 }
2152
2153 #[tokio::test]
2158 async fn merge_detection_does_not_fire_twice_for_the_same_session() {
2159 use crate::store::Store;
2160
2161 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2162 let mut s = test_session("w1", "/ws");
2163 s.status = SessionStatus::PrOpen;
2164 s.orchestrator_id = Some("orch1".into());
2165 s.pr_number = Some(7);
2166 store.upsert_session(&s).unwrap();
2167
2168 let engine = Engine::new(store.clone());
2169 let mut rx = engine.subscribe();
2170 let poller = Poller::new(engine);
2171
2172 let first = poller.handle_merge_detection(&s, 7, true).await;
2173 assert!(first, "first tick handles the merge");
2174
2175 let updated = store.get_session("w1").unwrap().unwrap();
2178 let second = poller.handle_merge_detection(&updated, 7, true).await;
2179 assert!(!second, "an already-Done session must not re-fire merge detection");
2180
2181 let events = drain_events(&mut rx);
2182 let merged_notifs = events.iter().filter(|e| matches!(
2183 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkerDone
2184 )).count();
2185 assert_eq!(merged_notifs, 1, "no duplicate WorkerDone notification across ticks");
2186 }
2187
2188 #[tokio::test]
2189 async fn handle_merge_detection_is_noop_when_pr_not_merged() {
2190 use crate::store::Store;
2191
2192 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2193 let s = test_session("w1", "/ws");
2194 store.upsert_session(&s).unwrap();
2195 let engine = Engine::new(store.clone());
2196 let poller = Poller::new(engine);
2197
2198 let handled = poller.handle_merge_detection(&s, 1, false).await;
2199 assert!(!handled);
2200 assert!(matches!(store.get_session("w1").unwrap().unwrap().status, SessionStatus::Working));
2201 }
2202
2203 #[tokio::test]
2210 async fn sweep_retired_sessions_purges_only_past_the_retention_window() {
2211 use crate::store::Store;
2212
2213 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2214 let retention = SessionRetentionConfig { done_retention_days: 2 };
2215 let now = now_millis();
2216 let retention_ms = retention.retention_millis();
2217
2218 let mut expired = test_session("expired", "/ws");
2219 expired.status = SessionStatus::Done;
2220 expired.terminal_at = Some(now - retention_ms - 1_000);
2221 store.upsert_session(&expired).unwrap();
2222
2223 let mut fresh = test_session("fresh", "/ws");
2224 fresh.status = SessionStatus::Done;
2225 fresh.terminal_at = Some(now - 1_000);
2226 store.upsert_session(&fresh).unwrap();
2227
2228 let engine = Engine::new(store.clone());
2229 let mut rx = engine.subscribe();
2230 let poller = Poller::new(engine);
2231
2232 poller.sweep_retired_sessions(&retention).await;
2233
2234 assert!(store.get_session("expired").unwrap().is_none(), "past-retention session must be purged");
2235 assert!(store.get_session("fresh").unwrap().is_some(), "within-retention session must survive");
2236
2237 let events = drain_events(&mut rx);
2238 assert!(events.iter().any(|e| matches!(e, Event::SessionDone(id) if id == "expired")));
2239 }
2240
2241 #[tokio::test]
2246 async fn sweep_retired_sessions_purges_sessions_with_no_terminal_at_immediately() {
2247 use crate::store::Store;
2248
2249 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2250 let mut s = test_session("user-killed", "/ws");
2251 s.status = SessionStatus::Terminated;
2252 s.terminal_at = None;
2253 store.upsert_session(&s).unwrap();
2254
2255 let engine = Engine::new(store.clone());
2256 let poller = Poller::new(engine);
2257
2258 poller.sweep_retired_sessions(&SessionRetentionConfig::default()).await;
2259
2260 assert!(store.get_session("user-killed").unwrap().is_none());
2261 }
2262
2263 #[tokio::test]
2266 async fn sweep_retired_sessions_never_purges_orchestrator_sessions() {
2267 use crate::store::Store;
2268
2269 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2270 store.upsert_orchestrator(&crate::types::Orchestrator {
2271 id: "orch1".into(), name: "orch".into(), created_at: 0,
2272 }).unwrap();
2273 let mut s = test_session("orch1", "/ws");
2274 s.status = SessionStatus::Done;
2275 s.terminal_at = Some(0); store.upsert_session(&s).unwrap();
2277
2278 let engine = Engine::new(store.clone());
2279 let poller = Poller::new(engine);
2280
2281 poller.sweep_retired_sessions(&SessionRetentionConfig::default()).await;
2282
2283 assert!(store.get_session("orch1").unwrap().is_some(), "orchestrator sessions are never auto-purged");
2284 }
2285
2286 #[tokio::test]
2289 async fn sweep_retired_sessions_ignores_non_terminal_sessions() {
2290 use crate::store::Store;
2291
2292 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2293 let mut s = test_session("still-working", "/ws");
2294 s.status = SessionStatus::Working;
2295 s.terminal_at = None;
2296 store.upsert_session(&s).unwrap();
2297
2298 let engine = Engine::new(store.clone());
2299 let poller = Poller::new(engine);
2300
2301 poller.sweep_retired_sessions(&SessionRetentionConfig::default()).await;
2302
2303 assert!(store.get_session("still-working").unwrap().is_some());
2304 }
2305}