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 Some(written) = self.update_live_session_row(&session, |row| {
250 row.pr_number = session.pr_number;
251 row.status = session.status.clone();
252 }) else {
253 continue;
257 };
258 self.engine.emit(Event::SessionUpdated(
259 written, SessionFields::PR_LINK | SessionFields::STATUS,
260 ));
261 tracing::info!(
262 "session {} PR #{} detected via metadata hook",
263 session.id, first.number
264 );
265 self.trigger_brain_harvest(&session).await;
266 }
267 }
268
269 let Some(tracked) = session.pr_number else { continue };
271
272 for report in meta.pr_reports.iter().filter(|r| r.number != tracked) {
277 if let Ok(None) = self.engine.store.get_pr(report.number as i64) {
278 let url = report.url.clone().unwrap_or_else(|| {
279 format!("https://github.com/{}/pull/{}", session.repo, report.number)
280 });
281 let pr = PR {
282 id: report.number as i64,
283 number: report.number,
284 title: String::new(),
285 url,
286 body: String::new(),
287 session_id: session.id.clone(),
288 };
289 let _ = self.engine.store.upsert_pr(&pr);
290 self.engine.emit(Event::ExtraPrDetected(pr));
296 }
297 }
298
299 let notified = hooks::read_notified_extra_prs(sessions_dir, &session.id);
301 let mut fresh: Vec<(u64, Option<String>)> = Vec::new();
302 for report in meta.pr_reports.iter()
303 .filter(|r| r.number != tracked && !notified.contains(&r.number))
304 {
305 self.engine.emit(Event::Notification(Notification {
306 id: format!("extra-pr-{}-{}", session.id, report.number),
307 kind: NotificationKind::ExtraPr,
308 title: format!("Extra PR — {}", session.name),
309 body: format!("#{} opened beyond tracked #{tracked}", report.number),
310 session_id: Some(session.id.clone()),
311 created_at: now_millis(),
312 }));
313 fresh.push((report.number, report.url.clone()));
314 }
315 if fresh.is_empty() {
316 continue;
317 }
318 let numbers: Vec<u64> = fresh.iter().map(|(n, _)| *n).collect();
319 if let Err(e) = hooks::mark_extra_prs_notified(sessions_dir, &session.id, &numbers) {
320 tracing::warn!("mark extra PRs notified for {}: {e}", session.id);
321 }
322 if let Some(orch) = session.orchestrator_id.clone() {
323 let msg = crate::lifecycle::reactions::format_extra_pr_reaction(
324 &session, tracked, &fresh,
325 );
326 if let Err(e) = self.engine.send_to_session(&orch, &msg).await {
327 tracing::warn!("send extra-PR reaction to orchestrator {orch}: {e}");
328 }
329 }
330 }
331 }
332
333 async fn trigger_brain_harvest(&self, session: &Session) {
348 let config = AppConfig::load().unwrap_or_default();
349 if !config.brain_harvest.enabled {
350 return;
351 }
352 let Some(workspace) = session.workspace_path.clone() else { return };
353 let workspace_path: PathBuf = workspace.into();
354 let brain_path: PathBuf = session.catalogue_path.clone()
360 .map(PathBuf::from)
361 .unwrap_or_else(|| config.resolved_brain_path());
362 let runner = self.harvest_runner.clone();
363 let session_id = session.id.clone();
364 let panic_session_id = session_id.clone();
365 let vault_lock = self.vault_lock(&brain_path);
366
367 let handle = tokio::spawn(async move {
368 let Some(diff) = brain_harvest::compute_nontrivial_diff(&workspace_path).await else {
369 tracing::info!("brain harvest skipped for {session_id}: no non-trivial diff");
370 return;
371 };
372 let prompt = brain_harvest::build_harvest_prompt(&session_id, &diff);
373
374 let _guard = vault_lock.lock().await;
378 if let Err(e) = runner.run(prompt, workspace_path, brain_path).await {
379 tracing::warn!("brain harvest failed for session {session_id}: {e}");
380 }
381 });
382
383 tokio::spawn(async move {
384 if let Err(join_err) = handle.await {
385 if join_err.is_panic() {
386 let payload = join_err.into_panic();
387 tracing::warn!(
388 "brain harvest task panicked for session {panic_session_id}: {}",
389 panic_message(payload.as_ref()),
390 );
391 } else {
392 tracing::warn!("brain harvest task for session {panic_session_id} was cancelled");
393 }
394 }
395 });
396 }
397
398 async fn deliver_work_requests(&self, session: &crate::types::Session, sessions_dir: &std::path::Path) {
401 let pending = match hooks::read_pending_work_requests(sessions_dir, &session.id) {
402 Ok(p) if !p.is_empty() => p,
403 _ => return,
404 };
405 for request in &pending {
406 self.engine.emit(Event::Notification(Notification {
407 id: format!("work-request-{}", request.id),
408 kind: NotificationKind::WorkRequested,
409 title: format!("Work requested — {}", session.name),
410 body: request.description.clone(),
411 session_id: Some(session.id.clone()),
412 created_at: now_millis(),
413 }));
414 if let Some(orch) = session.orchestrator_id.clone() {
415 let msg = crate::lifecycle::reactions::format_work_request_reaction(
416 session, &request.description,
417 );
418 if let Err(e) = self.engine.send_to_session(&orch, &msg).await {
419 tracing::warn!("send work request to orchestrator {orch}: {e}");
420 }
421 }
422 }
423 let ids: Vec<String> = pending.iter().map(|r| r.id.clone()).collect();
427 if let Err(e) = hooks::mark_work_requests_delivered(sessions_dir, &session.id, &ids) {
428 tracing::warn!("mark work requests delivered for {}: {e}", session.id);
429 }
430 }
431
432 async fn poll_usage(&self) {
441 let Ok(sessions) = self.engine.store.list_sessions() else { return };
442 for mut session in sessions {
443 if matches!(session.status, SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted) {
444 continue;
445 }
446 let Some(workspace) = session.workspace_path.clone() else { continue };
447 let Some(snapshot) = usage::ingest_usage_for_workspace(&workspace) else { continue };
448
449 let cost_changed = (session.cost_usd - snapshot.cost_usd).abs() > 1e-9;
450 let context_changed = session.context_tokens != Some(snapshot.context_tokens);
451 if !cost_changed && !context_changed {
452 continue;
453 }
454
455 session.cost_usd = snapshot.cost_usd;
456 session.context_tokens = Some(snapshot.context_tokens);
457 if session.model.is_none() {
458 session.model = snapshot.model;
459 }
460 let _ = self.engine.store.upsert_session(&session);
461 self.engine.emit(Event::SessionUpdated(
462 session, SessionFields::COST | SessionFields::CONTEXT | SessionFields::MODEL,
463 ));
464 }
465 }
466
467 async fn poll_context_updates(&self) {
478 let Ok(sessions) = self.engine.store.list_sessions() else { return };
479 let mut changed = Vec::new();
480 {
481 let mut cache = self.context_cache.lock().unwrap();
482 for session in sessions {
483 let key = (session.cost_usd, session.context_used_pct, session.context_total_tokens);
484 if let Some(prev) = cache.insert(session.id.clone(), key) {
488 if prev != key {
489 changed.push(session);
490 }
491 }
492 }
493 }
494 for session in changed {
495 self.engine.emit(Event::SessionUpdated(
496 session, SessionFields::COST | SessionFields::CONTEXT,
497 ));
498 }
499 }
500
501 fn update_live_session_row(
514 &self,
515 snapshot: &crate::types::Session,
516 apply: impl FnOnce(&mut crate::types::Session),
517 ) -> Option<crate::types::Session> {
518 let mut row = match self.engine.store.get_session(&snapshot.id) {
519 Ok(Some(row)) => row,
520 Ok(None) => return None,
521 Err(_) => snapshot.clone(),
522 };
523 apply(&mut row);
524 let _ = self.engine.store.upsert_session(&row);
525 Some(row)
526 }
527
528 async fn poll_github(&self) {
529 let Some(gh) = &self.engine.github else { return };
530 let Ok(sessions) = self.engine.store.list_sessions() else { return };
531
532 for mut session in sessions {
533 if matches!(session.status, SessionStatus::Done) {
541 continue;
542 }
543 let Some(pr_number) = session.pr_number else { continue };
544
545 let mut found: Option<(String, u64, crate::github::PrStatus)> = None;
557 let mut last_err: Option<anyhow::Error> = None;
558 let mut attempted = false;
559 if !session.repo.is_empty() {
560 if let Some((owner, repo)) = split_repo(&session.repo) {
561 attempted = true;
562 match gh.get_pr_status(&owner, &repo, pr_number).await {
563 Ok(s) => found = Some((session.repo.clone(), pr_number, s)),
564 Err(e) => last_err = Some(e),
565 }
566 }
567 }
568 if found.is_none() {
569 if let Some(workspace) = session.workspace_path.clone() {
570 if let Some(branch) = crate::github::current_branch(&workspace) {
571 for repo_slug in crate::github::candidate_repos(&workspace) {
572 if repo_slug == session.repo {
573 continue; }
575 let Some((owner, repo)) = split_repo(&repo_slug) else { continue };
576 attempted = true;
577 let pr_ref = match gh.find_open_pr_for_branch(&owner, &repo, &branch).await {
578 Ok(Some(r)) => r,
579 Ok(None) => continue,
580 Err(e) => { last_err = Some(e); continue; }
581 };
582 match gh.get_pr_status(&owner, &repo, pr_ref.number).await {
583 Ok(s) => { found = Some((repo_slug, pr_ref.number, s)); break; }
584 Err(e) => { last_err = Some(e); continue; }
585 }
586 }
587 }
588 }
589 }
590 if !attempted {
591 continue; }
593 let Some((resolved_repo, pr_number, pr_status)) = found else {
594 if let Some(e) = last_err {
595 tracing::warn!("github pr status for {}: {e}", session.id);
596 }
597 self.notify_github_lookup_failed(&session);
598 continue;
599 };
600 self.clear_github_lookup_failed(&session.id);
601
602 let pr_id: PrId = pr_number as i64;
603
604 if resolved_repo != session.repo
616 || Some(pr_number) != session.pr_number
617 || session.pr_id != Some(pr_id)
618 {
619 if resolved_repo != session.repo || Some(pr_number) != session.pr_number {
620 tracing::info!(
621 "session {} repo/PR corrected {}#{:?} -> {resolved_repo}#{pr_number}",
622 session.id, session.repo, session.pr_number,
623 );
624 }
625 session.repo = resolved_repo;
626 session.pr_number = Some(pr_number);
627 session.pr_id = Some(pr_id);
628 let written = self.update_live_session_row(&session, |row| {
629 row.repo = session.repo.clone();
630 row.pr_number = session.pr_number;
631 row.pr_id = session.pr_id;
632 });
633 if written.is_some() {
634 self.engine.emit(Event::SessionUpdated(
640 session.clone(), SessionFields::PR_LINK,
641 ));
642 }
643 }
644 let Some((owner, repo)) = split_repo(&session.repo) else { continue };
645
646 if self.handle_merge_detection(&session, pr_number, pr_status.merged).await {
648 continue; }
653
654 {
656 let pr = PR {
657 id: pr_id,
658 number: pr_number,
659 title: pr_status.title.clone(),
660 url: format!("https://github.com/{owner}/{repo}/pull/{pr_number}"),
661 body: String::new(),
662 session_id: session.id.clone(),
663 };
664 let _ = self.engine.store.upsert_pr(&pr);
665 self.engine.emit(Event::PrOpened { session_id: session.id.clone(), pr });
666 }
667
668 let checks = match gh.get_ci_checks(&owner, &repo, &pr_status.head_sha).await {
670 Ok(c) => c,
671 Err(e) => { tracing::warn!("github ci checks: {e}"); vec![] }
672 };
673 let ci = summarize_checks(pr_id, &checks);
674 let _ = self.engine.store.upsert_ci_status(&ci);
675 self.engine.emit(Event::CiUpdated { pr_id, status: ci.clone() });
676
677 let (newly_failing, ci_reaction_already_sent) = {
679 let mut cache = self.enrichment_cache.lock().unwrap();
680 let state = cache.entry(session.id.clone()).or_default();
681
682 let newly_failing = state.prev_failing.is_none_or(|p| p == 0)
683 && ci.failing > 0;
684 state.prev_failing = Some(ci.failing);
685
686 let already_sent = state.ci_reaction_sent;
687 if newly_failing && !already_sent {
688 state.ci_reaction_sent = true;
689 }
690 if ci.failing == 0 {
691 state.ci_reaction_sent = false;
692 }
693 (newly_failing, already_sent)
694 };
695
696 if newly_failing && !ci_reaction_already_sent {
697 self.engine.emit(Event::Notification(Notification {
698 id: format!("ci-{}", session.id),
699 kind: NotificationKind::CiFailure,
700 title: format!("CI failing — {}", session.name),
701 body: format!("{}/{} checks failing", ci.failing, ci.total),
702 session_id: Some(session.id.clone()),
703 created_at: now_millis(),
704 }));
705 let failing_names: Vec<String> = checks.iter()
707 .filter(|c| c.conclusion.as_deref() == Some("failure")
708 || c.conclusion.as_deref() == Some("timed_out"))
709 .map(|c| c.name.clone())
710 .collect();
711 let msg = crate::lifecycle::reactions::format_ci_reaction(
712 &session, &ci, &failing_names
713 );
714 if let Err(e) = self.engine.send_to_session(&session.id, &msg).await {
715 tracing::warn!("send ci reaction to {}: {e}", session.id);
716 }
717 }
718
719 let threads = match gh.get_review_threads(&owner, &repo, pr_number).await {
721 Ok(t) => t,
722 Err(e) => { tracing::warn!("github review threads: {e}"); vec![] }
723 };
724
725 let has_changes_requested = threads.iter().any(|t| t.state == "CHANGES_REQUESTED");
726
727 let (has_new, review_reaction_already_sent, new_comments) = {
728 let mut cache = self.enrichment_cache.lock().unwrap();
729 let state = cache.entry(session.id.clone()).or_default();
730 let mut has_new = false;
731 let mut new_comments: Vec<Comment> = Vec::new();
732
733 for thread in &threads {
734 if thread.state == "CHANGES_REQUESTED"
735 && !state.seen_comment_ids.contains(&thread.id)
736 {
737 state.seen_comment_ids.insert(thread.id);
738 has_new = true;
739 let comment = Comment {
740 id: thread.id,
741 pr_id,
742 author: thread.author.clone(),
743 body: thread.body.clone(),
744 path: thread.path.clone(),
745 line: thread.line,
746 created_at: 0,
747 };
748 let _ = self.engine.store.upsert_comment(&comment);
749 self.engine.emit(Event::ReviewComment { pr_id, comment: comment.clone() });
750 new_comments.push(comment);
751 }
752 }
753
754 let already_sent = state.review_reaction_sent;
755 if has_new && !already_sent {
756 state.review_reaction_sent = true;
757 }
758 if !has_changes_requested {
760 state.review_reaction_sent = false;
761 }
762 (has_new, already_sent, new_comments)
763 };
764
765 let new_status = derive_session_status(&session.status, &pr_status, &ci, has_changes_requested);
767 let new_gate = compute_new_gate(
768 &session.status, &ci, has_changes_requested, pr_status.mergeable,
769 session.gate_status.as_ref(), now_millis(),
770 );
771 if new_status != session.status || new_gate != session.gate_status {
772 if let Some(updated) = self.update_live_session_row(&session, |row| {
777 row.status = new_status;
778 row.gate_status = new_gate;
779 }) {
780 self.engine.emit(Event::SessionUpdated(
781 updated,
782 SessionFields::STATUS | SessionFields::GATE,
783 ));
784 }
785 }
786
787 if has_new && !review_reaction_already_sent {
788 self.engine.emit(Event::Notification(Notification {
789 id: format!("review-{}", session.id),
790 kind: NotificationKind::PrNeedsAttention,
791 title: format!("Review comments — {}", session.name),
792 body: "Changes requested on your PR".to_string(),
793 session_id: Some(session.id.clone()),
794 created_at: now_millis(),
795 }));
796 if !new_comments.is_empty() {
797 let msg = crate::lifecycle::reactions::format_review_reaction(
798 &session, &new_comments
799 );
800 if let Err(e) = self.engine.send_to_session(&session.id, &msg).await {
801 tracing::warn!("send review reaction to {}: {e}", session.id);
802 }
803 }
804 }
805 }
806 }
807
808 fn notify_github_lookup_failed(&self, session: &crate::types::Session) {
812 let already_notified = {
813 let mut cache = self.enrichment_cache.lock().unwrap();
814 let state = cache.entry(session.id.clone()).or_default();
815 let already = state.github_lookup_failed_notified;
816 state.github_lookup_failed_notified = true;
817 already
818 };
819 if already_notified {
820 return;
821 }
822 self.engine.emit(Event::Notification(Notification {
823 id: format!("github-lookup-failed-{}", session.id),
824 kind: NotificationKind::GithubLookupFailed,
825 title: format!("GitHub lookup failing — {}", session.name),
826 body: "PR status/CI/review polling failed against every configured remote"
827 .to_string(),
828 session_id: Some(session.id.clone()),
829 created_at: now_millis(),
830 }));
831 }
832
833 fn clear_github_lookup_failed(&self, session_id: &str) {
836 let mut cache = self.enrichment_cache.lock().unwrap();
837 if let Some(state) = cache.get_mut(session_id) {
838 state.github_lookup_failed_notified = false;
839 }
840 }
841
842 async fn poll_pr_reconciliation(&self) {
853 let Some(gh) = &self.engine.github else { return };
854 let Ok(sessions) = self.engine.store.list_sessions() else { return };
855
856 for mut session in sessions {
857 if session.pr_number.is_some() {
858 continue;
859 }
860 if matches!(session.status, SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted) {
861 continue;
862 }
863 let Some(workspace) = session.workspace_path.clone() else { continue };
864 let Some(branch) = crate::github::current_branch(&workspace) else { continue };
865
866 for repo_slug in crate::github::candidate_repos(&workspace) {
867 let Some((owner, repo)) = split_repo(&repo_slug) else { continue };
868 match gh.find_open_pr_for_branch(&owner, &repo, &branch).await {
869 Ok(Some(pr_ref)) => {
870 session.pr_number = Some(pr_ref.number);
871 session.repo = repo_slug.clone();
872 session.status = SessionStatus::PrOpen;
873 if let Some(written) = self.update_live_session_row(&session, |row| {
878 row.pr_number = session.pr_number;
879 row.repo = session.repo.clone();
880 row.status = session.status.clone();
881 }) {
882 self.engine.emit(Event::SessionUpdated(
883 written, SessionFields::PR_LINK | SessionFields::STATUS,
884 ));
885 tracing::info!(
886 "session {} PR #{} detected via reconciliation ({repo_slug}, branch {branch})",
887 session.id, pr_ref.number,
888 );
889 }
890 break;
891 }
892 Ok(None) => continue,
893 Err(e) => {
894 tracing::warn!("reconciliation lookup {repo_slug} branch {branch}: {e}");
895 continue;
896 }
897 }
898 }
899 }
900 }
901 async fn handle_merge_detection(
913 &self,
914 session: &Session,
915 pr_number: u64,
916 pr_merged: bool,
917 ) -> bool {
918 if !pr_merged || matches!(session.status, SessionStatus::Done) {
919 return false;
920 }
921 self.engine.emit(Event::Notification(Notification {
922 id: format!("merged-{}", session.id),
923 kind: NotificationKind::WorkerDone,
924 title: format!("PR merged — {}", session.name),
925 body: format!("#{} merged successfully", pr_number),
926 session_id: Some(session.id.clone()),
927 created_at: now_millis(),
928 }));
929 if let Some(orch) = session.orchestrator_id.clone() {
932 let msg = crate::lifecycle::reactions::format_worker_done_reaction(session, pr_number);
933 if let Err(e) = self.engine.send_to_session(&orch, &msg).await {
934 tracing::warn!("send worker-done reaction to orchestrator {orch}: {e}");
935 }
936 }
937 if let Err(e) = self.engine.cleanup_session(&session.id).await {
938 tracing::warn!("cleanup_session {}: {e}", session.id);
939 }
940 {
942 let mut cache = self.enrichment_cache.lock().unwrap();
943 cache.remove(&session.id);
944 }
945 true
946 }
947
948 async fn sweep_retired_sessions(&self, retention: &SessionRetentionConfig) {
961 let Ok(sessions) = self.engine.store.list_sessions() else { return };
962 let Ok(orchestrators) = self.engine.store.list_orchestrators() else { return };
963 let orch_ids: std::collections::HashSet<&str> =
964 orchestrators.iter().map(|o| o.id.as_str()).collect();
965
966 let now = now_millis();
967 let retention_ms = retention.retention_millis();
968
969 for session in sessions {
970 if !matches!(session.status, SessionStatus::Done | SessionStatus::Terminated) {
971 continue;
972 }
973 if orch_ids.contains(session.id.as_str()) {
974 continue;
975 }
976 let expired = match session.terminal_at {
977 Some(t) => now.saturating_sub(t) >= retention_ms,
978 None => true,
979 };
980 if !expired {
981 continue;
982 }
983 if matches!(session.status, SessionStatus::Terminated) {
992 self.engine.emit(Event::Notification(Notification {
993 id: format!("retired-{}", session.id),
994 kind: NotificationKind::WorkerRetired,
995 title: format!("Worker retired — {}", session.name),
996 body: "Session cleaned up without a detected PR merge".to_string(),
997 session_id: Some(session.id.clone()),
998 created_at: now,
999 }));
1000 if let Some(orch) = session.orchestrator_id.clone() {
1001 let msg = crate::lifecycle::reactions::format_worker_retired_reaction(&session);
1002 if let Err(e) = self.engine.send_to_session(&orch, &msg).await {
1003 tracing::warn!("send worker-retired reaction to orchestrator {orch}: {e}");
1004 }
1005 }
1006 }
1007 if let Err(e) = self.engine.remove_session(&session.id).await {
1012 tracing::warn!("purge retired session {}: {e}", session.id);
1013 continue;
1014 }
1015 }
1016 }
1017}
1018
1019fn summarize_checks(pr_id: PrId, checks: &[CheckRun]) -> CIStatus {
1022 let total = checks.len() as u32;
1023 let failing = checks.iter().filter(|c| {
1024 c.conclusion.as_deref() == Some("failure")
1025 || c.conclusion.as_deref() == Some("timed_out")
1026 }).count() as u32;
1027 let passing = checks.iter().filter(|c| {
1028 c.conclusion.as_deref() == Some("success")
1029 }).count() as u32;
1030 let pending = total - failing - passing;
1031 CIStatus { pr_id, total, failing, passing, pending }
1032}
1033
1034fn derive_session_status(
1035 current: &SessionStatus,
1036 pr_status: &crate::github::PrStatus,
1037 ci: &CIStatus,
1038 has_changes_requested: bool,
1039) -> SessionStatus {
1040 if matches!(current, SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted) {
1042 return current.clone();
1043 }
1044 if pr_status.merged {
1045 return SessionStatus::Done;
1046 }
1047 if ci.failing > 0 {
1048 return SessionStatus::CiFailed;
1049 }
1050 if has_changes_requested {
1051 return SessionStatus::ReviewPending;
1052 }
1053 if pr_status.mergeable == Some(true) && ci.failing == 0 && ci.pending == 0 {
1054 return SessionStatus::Mergeable;
1055 }
1056 SessionStatus::PrOpen
1057}
1058
1059fn derive_gate_status(
1060 ci: &CIStatus,
1061 has_changes_requested: bool,
1062 mergeable: Option<bool>,
1063 previous: Option<&GateStatus>,
1064 now: i64,
1065) -> GateStatus {
1066 let ci_check = if ci.failing > 0 {
1067 GateCheck::Failing
1068 } else if ci.pending > 0 {
1069 GateCheck::Pending
1070 } else {
1071 GateCheck::Passing
1072 };
1073 let review_check = if has_changes_requested { GateCheck::Failing } else { GateCheck::Passing };
1074 let mergeable_check = match mergeable {
1075 Some(true) => GateCheck::Passing,
1076 Some(false) => GateCheck::Failing,
1077 None => GateCheck::Unknown,
1078 };
1079
1080 let unchanged = previous.is_some_and(|p| {
1081 p.ci == ci_check && p.review == review_check && p.mergeable == mergeable_check
1082 });
1083 let since = if unchanged { previous.unwrap().since } else { now };
1084
1085 GateStatus { ci: ci_check, review: review_check, mergeable: mergeable_check, since }
1086}
1087
1088fn compute_new_gate(
1103 current_status: &SessionStatus,
1104 ci: &CIStatus,
1105 has_changes_requested: bool,
1106 mergeable: Option<bool>,
1107 previous_gate: Option<&GateStatus>,
1108 now: i64,
1109) -> Option<GateStatus> {
1110 if matches!(current_status, SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted) {
1111 return previous_gate.cloned();
1112 }
1113 Some(derive_gate_status(ci, has_changes_requested, mergeable, previous_gate, now))
1114}
1115
1116#[cfg(test)]
1117mod tests {
1118 use super::*;
1119 use crate::types::SessionStatus;
1120
1121 #[test]
1122 fn summarize_checks_counts_failures() {
1123 let checks = vec![
1124 CheckRun { name: "lint".into(), status: "completed".into(), conclusion: Some("success".into()) },
1125 CheckRun { name: "test".into(), status: "completed".into(), conclusion: Some("failure".into()) },
1126 CheckRun { name: "build".into(), status: "in_progress".into(), conclusion: None },
1127 ];
1128 let ci = summarize_checks(1, &checks);
1129 assert_eq!(ci.total, 3);
1130 assert_eq!(ci.passing, 1);
1131 assert_eq!(ci.failing, 1);
1132 assert_eq!(ci.pending, 1);
1133 }
1134
1135 #[test]
1136 fn derive_status_merged_becomes_done() {
1137 let pr = crate::github::PrStatus {
1138 merged: true, state: "closed".into(), mergeable: None,
1139 title: "t".into(), number: 1, head_sha: String::new(),
1140 };
1141 let ci = CIStatus { pr_id: 1, total: 0, failing: 0, passing: 0, pending: 0 };
1142 let s = derive_session_status(&SessionStatus::PrOpen, &pr, &ci, false);
1143 assert!(matches!(s, SessionStatus::Done));
1144 }
1145
1146 #[test]
1147 fn derive_status_ci_failure_overrides_open() {
1148 let pr = crate::github::PrStatus {
1149 merged: false, state: "open".into(), mergeable: Some(true),
1150 title: "t".into(), number: 1, head_sha: String::new(),
1151 };
1152 let ci = CIStatus { pr_id: 1, total: 3, failing: 1, passing: 2, pending: 0 };
1153 let s = derive_session_status(&SessionStatus::PrOpen, &pr, &ci, false);
1154 assert!(matches!(s, SessionStatus::CiFailed));
1155 }
1156
1157 #[test]
1158 fn derive_status_all_green_becomes_mergeable() {
1159 let pr = crate::github::PrStatus {
1160 merged: false, state: "open".into(), mergeable: Some(true),
1161 title: "t".into(), number: 1, head_sha: String::new(),
1162 };
1163 let ci = CIStatus { pr_id: 1, total: 3, failing: 0, passing: 3, pending: 0 };
1164 let s = derive_session_status(&SessionStatus::PrOpen, &pr, &ci, false);
1165 assert!(matches!(s, SessionStatus::Mergeable));
1166 }
1167
1168 #[test]
1169 fn derive_gate_status_maps_raw_signals() {
1170 let ci = CIStatus { pr_id: 1, total: 3, passing: 1, failing: 1, pending: 1 };
1171 let gate = derive_gate_status(&ci, true, Some(false), None, 1_000);
1172 assert!(matches!(gate.ci, GateCheck::Failing));
1173 assert!(matches!(gate.review, GateCheck::Failing));
1174 assert!(matches!(gate.mergeable, GateCheck::Failing));
1175 assert_eq!(gate.since, 1_000, "first observation stamps `since` to `now`");
1176 }
1177
1178 #[test]
1179 fn derive_gate_status_maps_all_passing() {
1180 let ci = CIStatus { pr_id: 1, total: 2, passing: 2, failing: 0, pending: 0 };
1181 let gate = derive_gate_status(&ci, false, Some(true), None, 1_000);
1182 assert!(matches!(gate.ci, GateCheck::Passing));
1183 assert!(matches!(gate.review, GateCheck::Passing));
1184 assert!(matches!(gate.mergeable, GateCheck::Passing));
1185 }
1186
1187 #[test]
1188 fn derive_gate_status_maps_pending_ci_and_unknown_mergeable() {
1189 let ci = CIStatus { pr_id: 1, total: 2, passing: 0, failing: 0, pending: 2 };
1190 let gate = derive_gate_status(&ci, false, None, None, 1_000);
1191 assert!(matches!(gate.ci, GateCheck::Pending));
1192 assert!(matches!(gate.mergeable, GateCheck::Unknown));
1193 }
1194
1195 #[test]
1196 fn derive_gate_status_carries_since_forward_when_unchanged() {
1197 let ci = CIStatus { pr_id: 1, total: 1, passing: 1, failing: 0, pending: 0 };
1198 let previous = GateStatus {
1199 ci: GateCheck::Passing, review: GateCheck::Passing,
1200 mergeable: GateCheck::Passing, since: 500,
1201 };
1202 let gate = derive_gate_status(&ci, false, Some(true), Some(&previous), 9_999);
1203 assert_eq!(gate.since, 500, "unchanged combination keeps the original `since`");
1204 }
1205
1206 #[test]
1207 fn derive_gate_status_resets_since_when_combination_changes() {
1208 let ci = CIStatus { pr_id: 1, total: 1, passing: 0, failing: 1, pending: 0 };
1209 let previous = GateStatus {
1210 ci: GateCheck::Passing, review: GateCheck::Passing,
1211 mergeable: GateCheck::Passing, since: 500,
1212 };
1213 let gate = derive_gate_status(&ci, false, Some(true), Some(&previous), 9_999);
1214 assert!(matches!(gate.ci, GateCheck::Failing));
1215 assert_eq!(gate.since, 9_999, "a changed combination resets `since` to `now`");
1216 }
1217
1218 #[test]
1225 fn compute_new_gate_preserves_terminal_gate_even_when_live_signals_changed() {
1226 let previous = GateStatus {
1227 ci: GateCheck::Failing, review: GateCheck::Failing,
1228 mergeable: GateCheck::Failing, since: 111,
1229 };
1230 let ci = CIStatus { pr_id: 1, total: 3, passing: 3, failing: 0, pending: 0 };
1234
1235 for status in [SessionStatus::Terminated, SessionStatus::Interrupted, SessionStatus::Done] {
1236 let gate = compute_new_gate(&status, &ci, false, Some(true), Some(&previous), 9_999);
1237 assert_eq!(
1238 gate, Some(previous.clone()),
1239 "{status:?} session's gate must be carried forward unchanged, not recomputed",
1240 );
1241 }
1242 }
1243
1244 #[test]
1245 fn compute_new_gate_recomputes_for_non_terminal_status() {
1246 let previous = GateStatus {
1247 ci: GateCheck::Failing, review: GateCheck::Failing,
1248 mergeable: GateCheck::Failing, since: 111,
1249 };
1250 let ci = CIStatus { pr_id: 1, total: 3, passing: 3, failing: 0, pending: 0 };
1251
1252 let gate = compute_new_gate(&SessionStatus::PrOpen, &ci, false, Some(true), Some(&previous), 9_999);
1253 let gate = gate.expect("non-terminal status must produce a fresh gate");
1254 assert!(matches!(gate.ci, GateCheck::Passing), "must reflect the live all-green signals");
1255 assert_eq!(gate.since, 9_999, "changed combination resets `since` to `now`");
1256 }
1257
1258 #[test]
1259 fn derive_status_preserves_done() {
1260 let pr = crate::github::PrStatus {
1261 merged: false, state: "open".into(), mergeable: Some(true),
1262 title: "t".into(), number: 1, head_sha: String::new(),
1263 };
1264 let ci = CIStatus { pr_id: 1, total: 0, failing: 0, passing: 0, pending: 0 };
1265 let s = derive_session_status(&SessionStatus::Done, &pr, &ci, false);
1266 assert!(matches!(s, SessionStatus::Done));
1267 }
1268
1269 #[test]
1270 fn derive_status_preserves_terminated() {
1271 let pr = crate::github::PrStatus {
1272 merged: true, state: "closed".into(), mergeable: None, title: "t".into(), number: 1, head_sha: String::new(),
1274 };
1275 let ci = CIStatus { pr_id: 1, total: 0, failing: 0, passing: 0, pending: 0 };
1276 let s = derive_session_status(&SessionStatus::Terminated, &pr, &ci, false);
1277 assert!(matches!(s, SessionStatus::Terminated)); }
1279
1280 #[test]
1281 fn derive_status_changes_requested_becomes_review_pending() {
1282 let pr = crate::github::PrStatus {
1283 merged: false, state: "open".into(), mergeable: Some(true),
1284 title: "t".into(), number: 1, head_sha: String::new(),
1285 };
1286 let ci = CIStatus { pr_id: 1, total: 3, failing: 0, passing: 3, pending: 0 };
1287 let s = derive_session_status(&SessionStatus::PrOpen, &pr, &ci, true);
1288 assert!(matches!(s, SessionStatus::ReviewPending));
1289 }
1290
1291 fn test_session(id: &str, workspace: &str) -> crate::types::Session {
1292 crate::types::Session {
1293 id: id.into(), orchestrator_id: None, name: id.into(),
1294 repo: String::new(), status: SessionStatus::Working,
1295 agent_type: "claude-code".into(), cost_usd: 0.0, started_at: 0,
1296 pr_number: None, pr_id: None,
1297 workspace_path: Some(workspace.into()), pid: None,
1298 model: None, context_tokens: None, catalogue_path: None,
1299 context_used_pct: None, context_total_tokens: None, context_window_size: None,
1300 claude_session_id: None,
1301 summary: None,
1302 terminal_at: None, gate_status: None,
1303 }
1304 }
1305
1306 #[tokio::test]
1307 async fn poll_pids_leaves_interrupted_sessions_alone() {
1308 use crate::store::Store;
1309
1310 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1311 let mut s = test_session("interrupted-1", "/ws");
1312 s.status = SessionStatus::Interrupted;
1313 s.pid = Some(999_999); store.upsert_session(&s).unwrap();
1315 let engine = Engine::new(store.clone());
1316 let poller = Poller::new(engine);
1317
1318 poller.poll_pids().await;
1319
1320 let after = store.get_session("interrupted-1").unwrap().unwrap();
1321 assert!(
1322 matches!(after.status, SessionStatus::Interrupted),
1323 "poll_pids must not re-terminate an Interrupted session just because its stale pid is dead",
1324 );
1325 }
1326
1327 #[allow(clippy::await_holding_lock)]
1340 #[tokio::test]
1341 async fn poll_usage_ingests_transcript_into_store_and_emits_update() {
1342 use crate::{lifecycle::usage::{claude_project_slug, ENV_TEST_GUARD}, store::Store};
1343 use std::io::Write;
1344
1345 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1346 let projects_dir = tempfile::tempdir().unwrap();
1347 let workspace = "/tmp/poller-usage-probe-workspace";
1348 let project_dir = projects_dir.path().join(claude_project_slug(workspace));
1349 std::fs::create_dir_all(&project_dir).unwrap();
1350 let mut f = std::fs::File::create(project_dir.join("s.jsonl")).unwrap();
1351 writeln!(
1352 f,
1353 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}}}}}}"#
1354 ).unwrap();
1355 drop(f);
1356
1357 let prior = std::env::var("NINOX_CLAUDE_PROJECTS_DIR").ok();
1358 std::env::set_var("NINOX_CLAUDE_PROJECTS_DIR", projects_dir.path());
1359
1360 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1361 store.upsert_session(&test_session("s1", workspace)).unwrap();
1362 let engine = Engine::new(store.clone());
1363 let mut rx = engine.subscribe();
1364 let poller = Poller::new(engine);
1365
1366 poller.poll_usage().await;
1367
1368 match prior {
1369 Some(v) => std::env::set_var("NINOX_CLAUDE_PROJECTS_DIR", v),
1370 None => std::env::remove_var("NINOX_CLAUDE_PROJECTS_DIR"),
1371 }
1372
1373 let updated = store.get_session("s1").unwrap().unwrap();
1374 assert!(updated.cost_usd > 0.0, "cost_usd should be ingested, not 0.0000");
1375 assert_eq!(updated.context_tokens, Some(2 + 500 + 45000));
1376 assert_eq!(updated.model.as_deref(), Some("claude-fable-5"));
1377
1378 let evt = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
1379 .await
1380 .expect("SessionUpdated should be emitted")
1381 .unwrap();
1382 assert!(matches!(evt, Event::SessionUpdated(s, _fields) if s.id == "s1" && s.cost_usd > 0.0));
1383 }
1384
1385 #[tokio::test]
1391 async fn poll_context_updates_emits_only_for_changed_sessions() {
1392 use crate::store::Store;
1393
1394 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1395 let mut s1 = test_session("s1", "/ws1");
1396 let s2 = test_session("s2", "/ws2");
1397 store.upsert_session(&s1).unwrap();
1398 store.upsert_session(&s2).unwrap();
1399 let engine = Engine::new(store.clone());
1400 let mut rx = engine.subscribe();
1401 let poller = Poller::new(engine);
1402
1403 poller.poll_context_updates().await;
1407 let baseline_events = drain_events(&mut rx);
1408 assert!(baseline_events.is_empty(), "no prior cached state means no change to report");
1409
1410 s1.context_used_pct = Some(42.0);
1412 s1.cost_usd = 3.5;
1413 store.upsert_session(&s1).unwrap();
1414
1415 poller.poll_context_updates().await;
1416 let events = drain_events(&mut rx);
1417 assert_eq!(events.len(), 1, "only the changed session should emit");
1418 assert!(matches!(
1419 &events[0],
1420 Event::SessionUpdated(s, _fields) if s.id == "s1" && s.context_used_pct == Some(42.0) && s.cost_usd == 3.5
1421 ));
1422
1423 poller.poll_context_updates().await;
1425 assert!(drain_events(&mut rx).is_empty());
1426 }
1427
1428 fn drain_events(rx: &mut tokio::sync::broadcast::Receiver<Event>) -> Vec<Event> {
1430 let mut events = Vec::new();
1431 while let Ok(e) = rx.try_recv() {
1432 events.push(e);
1433 }
1434 events
1435 }
1436
1437 #[tokio::test]
1441 async fn metadata_sync_adopts_first_pr_and_flags_every_extra_once() {
1442 use crate::store::Store;
1443
1444 let sessions_dir = tempfile::tempdir().unwrap();
1445 let meta = serde_json::json!({
1446 "agentReportedPrNumber": "44",
1447 "agentReportedPrUrl": "https://github.com/org/repo/pull/44",
1448 "agentReportedPrs": [
1449 {"number": "42", "url": "https://github.com/org/repo/pull/42"},
1450 {"number": "43", "url": "https://github.com/org/repo/pull/43"},
1451 {"number": "44", "url": "https://github.com/org/repo/pull/44"},
1452 ],
1453 });
1454 std::fs::write(
1455 sessions_dir.path().join("s1.json"),
1456 serde_json::to_string(&meta).unwrap(),
1457 ).unwrap();
1458
1459 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1460 store.upsert_session(&test_session("s1", "/ws")).unwrap();
1461 let engine = Engine::new(store.clone());
1462 let mut rx = engine.subscribe();
1463 let poller = Poller::new(engine);
1464
1465 poller.sync_sessions_metadata(sessions_dir.path()).await;
1466
1467 let session = store.get_session("s1").unwrap().unwrap();
1468 assert_eq!(session.pr_number, Some(42), "first PR is the canonical one");
1469 assert!(matches!(session.status, SessionStatus::PrOpen));
1470 assert!(store.get_pr(43).unwrap().is_some(), "extra PR #43 recorded");
1471 assert!(store.get_pr(44).unwrap().is_some(), "extra PR #44 recorded");
1472 assert_eq!(
1473 store.get_pr(43).unwrap().unwrap().url,
1474 "https://github.com/org/repo/pull/43",
1475 );
1476
1477 let events = drain_events(&mut rx);
1478 let extra_notifs: Vec<_> = events.iter().filter(|e| matches!(
1479 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::ExtraPr
1480 )).collect();
1481 assert_eq!(extra_notifs.len(), 2, "one ExtraPr notification per extra PR");
1482
1483 let mut extra_pr_numbers: Vec<u64> = events.iter().filter_map(|e| match e {
1487 Event::ExtraPrDetected(pr) => Some(pr.number),
1488 _ => None,
1489 }).collect();
1490 extra_pr_numbers.sort();
1491 assert_eq!(
1492 extra_pr_numbers, vec![43, 44],
1493 "ExtraPrDetected must fire for every extra PR, not just the notification",
1494 );
1495 let pr_43 = events.iter().find_map(|e| match e {
1496 Event::ExtraPrDetected(pr) if pr.number == 43 => Some(pr),
1497 _ => None,
1498 }).expect("ExtraPrDetected for #43");
1499 assert_eq!(pr_43.session_id, "s1", "the ledger entry must point back at the owning session");
1500
1501 poller.sync_sessions_metadata(sessions_dir.path()).await;
1503 let events = drain_events(&mut rx);
1504 assert!(
1505 !events.iter().any(|e| matches!(e, Event::ExtraPrDetected(_))),
1506 "an extra PR already recorded in the store must not be re-detected on every tick",
1507 );
1508 assert!(
1509 !events.iter().any(|e| matches!(e, Event::Notification(_))),
1510 "extra PRs must not be re-notified on every tick",
1511 );
1512 }
1513
1514 #[tokio::test]
1517 async fn metadata_sync_single_pr_has_no_extra_notifications() {
1518 use crate::store::Store;
1519
1520 let sessions_dir = tempfile::tempdir().unwrap();
1521 let meta = serde_json::json!({
1522 "agentReportedPrNumber": "5",
1523 "agentReportedPrUrl": "https://github.com/org/repo/pull/5",
1524 });
1525 std::fs::write(
1526 sessions_dir.path().join("s1.json"),
1527 serde_json::to_string(&meta).unwrap(),
1528 ).unwrap();
1529
1530 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1531 store.upsert_session(&test_session("s1", "/ws")).unwrap();
1532 let engine = Engine::new(store.clone());
1533 let mut rx = engine.subscribe();
1534 let poller = Poller::new(engine);
1535
1536 poller.sync_sessions_metadata(sessions_dir.path()).await;
1537
1538 let session = store.get_session("s1").unwrap().unwrap();
1539 assert_eq!(session.pr_number, Some(5));
1540 assert!(matches!(session.status, SessionStatus::PrOpen));
1541 let events = drain_events(&mut rx);
1542 assert!(!events.iter().any(|e| matches!(e, Event::Notification(_))));
1543 }
1544
1545 #[tokio::test]
1548 async fn metadata_sync_delivers_work_requests_exactly_once() {
1549 use crate::store::Store;
1550
1551 let sessions_dir = tempfile::tempdir().unwrap();
1552 hooks::append_work_request(sessions_dir.path(), "s1", "Migrate the config loader").unwrap();
1553
1554 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1555 store.upsert_session(&test_session("s1", "/ws")).unwrap();
1556 let engine = Engine::new(store.clone());
1557 let mut rx = engine.subscribe();
1558 let poller = Poller::new(engine);
1559
1560 poller.sync_sessions_metadata(sessions_dir.path()).await;
1561
1562 let events = drain_events(&mut rx);
1563 let notif = events.iter().find_map(|e| match e {
1564 Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkRequested => Some(n),
1565 _ => None,
1566 }).expect("WorkRequested notification emitted");
1567 assert!(notif.body.contains("Migrate the config loader"));
1568 assert_eq!(notif.session_id.as_deref(), Some("s1"));
1569
1570 assert!(
1571 hooks::read_pending_work_requests(sessions_dir.path(), "s1").unwrap().is_empty(),
1572 "delivered requests must leave the pending set",
1573 );
1574
1575 poller.sync_sessions_metadata(sessions_dir.path()).await;
1576 let events = drain_events(&mut rx);
1577 assert!(
1578 !events.iter().any(|e| matches!(e, Event::Notification(_))),
1579 "delivered work requests must not fire again",
1580 );
1581 }
1582
1583 #[tokio::test]
1586 async fn metadata_sync_delivers_work_requests_from_terminated_sessions() {
1587 use crate::store::Store;
1588
1589 let sessions_dir = tempfile::tempdir().unwrap();
1590 hooks::append_work_request(sessions_dir.path(), "s1", "Follow-up refactor").unwrap();
1591
1592 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1593 let mut session = test_session("s1", "/ws");
1594 session.status = SessionStatus::Terminated;
1595 store.upsert_session(&session).unwrap();
1596 let engine = Engine::new(store.clone());
1597 let mut rx = engine.subscribe();
1598 let poller = Poller::new(engine);
1599
1600 poller.sync_sessions_metadata(sessions_dir.path()).await;
1601
1602 let events = drain_events(&mut rx);
1603 assert!(
1604 events.iter().any(|e| matches!(
1605 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkRequested
1606 )),
1607 "work requests outlive their session",
1608 );
1609 }
1610
1611 #[tokio::test]
1615 async fn extra_pr_ledger_row_backfills_after_notification_without_renotifying() {
1616 use crate::store::Store;
1617
1618 let sessions_dir = tempfile::tempdir().unwrap();
1619 let meta = serde_json::json!({
1620 "agentReportedPrs": [
1621 {"number": "7", "url": "https://github.com/org/repo/pull/7"},
1622 {"number": "9", "url": "https://github.com/org/repo/pull/9"},
1623 ],
1624 });
1625 std::fs::write(
1626 sessions_dir.path().join("s1.json"),
1627 serde_json::to_string(&meta).unwrap(),
1628 ).unwrap();
1629 hooks::mark_extra_prs_notified(sessions_dir.path(), "s1", &[9]).unwrap();
1631
1632 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1633 store.upsert_session(&test_session("s1", "/ws")).unwrap();
1634 let engine = Engine::new(store.clone());
1635 let mut rx = engine.subscribe();
1636 let poller = Poller::new(engine);
1637
1638 poller.sync_sessions_metadata(sessions_dir.path()).await;
1639
1640 assert!(
1641 store.get_pr(9).unwrap().is_some(),
1642 "already-notified extra PR must still get its ledger row backfilled",
1643 );
1644 let events = drain_events(&mut rx);
1645 assert!(
1646 !events.iter().any(|e| matches!(
1647 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::ExtraPr
1648 )),
1649 "backfilling the row must not re-notify",
1650 );
1651 }
1652
1653 #[tokio::test]
1657 async fn extra_pr_detection_survives_cross_repo_pr_number_collision() {
1658 use crate::store::Store;
1659
1660 let sessions_dir = tempfile::tempdir().unwrap();
1661 let meta = serde_json::json!({
1662 "agentReportedPrs": [
1663 {"number": "7", "url": "https://github.com/org/repo-a/pull/7"},
1664 {"number": "9", "url": "https://github.com/org/repo-a/pull/9"},
1665 ],
1666 });
1667 std::fs::write(
1668 sessions_dir.path().join("s1.json"),
1669 serde_json::to_string(&meta).unwrap(),
1670 ).unwrap();
1671
1672 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1673 store.upsert_session(&test_session("s1", "/ws")).unwrap();
1674 let other = PR {
1676 id: 9, number: 9, title: "other repo's PR".into(),
1677 url: "https://github.com/org/repo-b/pull/9".into(),
1678 body: String::new(), session_id: "other".into(),
1679 };
1680 store.upsert_pr(&other).unwrap();
1681
1682 let engine = Engine::new(store.clone());
1683 let mut rx = engine.subscribe();
1684 let poller = Poller::new(engine);
1685
1686 poller.sync_sessions_metadata(sessions_dir.path()).await;
1687
1688 let events = drain_events(&mut rx);
1689 assert!(
1690 events.iter().any(|e| matches!(
1691 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::ExtraPr
1692 )),
1693 "the collision must not suppress the extra-PR alert",
1694 );
1695 let row = store.get_pr(9).unwrap().unwrap();
1696 assert_eq!(row.session_id, "other", "the other repo's row must not be stolen");
1697 assert_eq!(row.url, "https://github.com/org/repo-b/pull/9");
1698 assert!(
1699 !events.iter().any(|e| matches!(e, Event::ExtraPrDetected(pr) if pr.number == 9)),
1700 "must not emit ExtraPrDetected for a row it didn't (and mustn't) write — that \
1701 would let s1 clobber the ledger UI's view of another session's PR",
1702 );
1703
1704 poller.sync_sessions_metadata(sessions_dir.path()).await;
1705 let events = drain_events(&mut rx);
1706 assert!(
1707 !events.iter().any(|e| matches!(e, Event::Notification(_))),
1708 "dedup must hold across ticks even without a prs row of our own",
1709 );
1710 }
1711
1712 #[tokio::test]
1713 async fn poll_usage_leaves_sessions_without_workspace_or_usage_untouched() {
1714 use crate::store::Store;
1715
1716 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1717 let mut no_ws = test_session("no-ws", "/does/not/matter");
1718 no_ws.workspace_path = None;
1719 store.upsert_session(&no_ws).unwrap();
1720 let engine = Engine::new(store.clone());
1721 let poller = Poller::new(engine);
1722
1723 poller.poll_usage().await;
1724
1725 let unchanged = store.get_session("no-ws").unwrap().unwrap();
1726 assert_eq!(unchanged.cost_usd, 0.0);
1727 assert_eq!(unchanged.context_tokens, None);
1728 }
1729
1730 #[derive(Default)]
1735 struct FakeGithub {
1736 pr_status_ok: std::sync::Mutex<HashMap<(String, String, u64), crate::github::PrStatus>>,
1740 branch_matches: std::sync::Mutex<HashMap<(String, String, String), crate::github::PrRef>>,
1741 calls: std::sync::Mutex<Vec<(String, String, u64)>>,
1743 mid_review_upsert: std::sync::Mutex<Option<(std::sync::Arc<crate::store::Store>, crate::types::Session)>>,
1749 mid_pr_status_upsert: std::sync::Mutex<Option<(std::sync::Arc<crate::store::Store>, crate::types::Session)>>,
1753 mid_review_delete: std::sync::Mutex<Option<(std::sync::Arc<crate::store::Store>, String)>>,
1757 mid_branch_lookup_upsert: std::sync::Mutex<Option<(std::sync::Arc<crate::store::Store>, crate::types::Session)>>,
1761 }
1762
1763 #[async_trait::async_trait]
1764 impl crate::github::GithubApi for FakeGithub {
1765 async fn get_pr_status(&self, owner: &str, repo: &str, pr_number: u64) -> anyhow::Result<crate::github::PrStatus> {
1766 self.calls.lock().unwrap().push((owner.to_string(), repo.to_string(), pr_number));
1767 if let Some((store, row)) = self.mid_pr_status_upsert.lock().unwrap().take() {
1768 store.upsert_session(&row).unwrap();
1769 }
1770 self.pr_status_ok.lock().unwrap()
1771 .get(&(owner.to_string(), repo.to_string(), pr_number))
1772 .cloned()
1773 .ok_or_else(|| anyhow::anyhow!("404 for {owner}/{repo}#{pr_number}"))
1774 }
1775 async fn get_ci_checks(&self, _owner: &str, _repo: &str, _head_sha: &str) -> anyhow::Result<Vec<CheckRun>> {
1776 Ok(vec![])
1777 }
1778 async fn get_review_threads(&self, _owner: &str, _repo: &str, _pr_number: u64) -> anyhow::Result<Vec<crate::github::ReviewThread>> {
1779 if let Some((store, row)) = self.mid_review_upsert.lock().unwrap().take() {
1780 store.upsert_session(&row).unwrap();
1781 }
1782 if let Some((store, id)) = self.mid_review_delete.lock().unwrap().take() {
1783 store.delete_session(&id).unwrap();
1784 }
1785 Ok(vec![])
1786 }
1787 async fn find_open_pr_for_branch(&self, owner: &str, repo: &str, branch: &str) -> anyhow::Result<Option<crate::github::PrRef>> {
1788 if let Some((store, row)) = self.mid_branch_lookup_upsert.lock().unwrap().take() {
1789 store.upsert_session(&row).unwrap();
1790 }
1791 Ok(self.branch_matches.lock().unwrap()
1792 .get(&(owner.to_string(), repo.to_string(), branch.to_string()))
1793 .cloned())
1794 }
1795 }
1796
1797 fn init_git_repo(branch: &str, remotes: &[(&str, &str)]) -> tempfile::TempDir {
1798 let dir = tempfile::tempdir().unwrap();
1799 let workspace = dir.path().to_string_lossy().to_string();
1800 let run = |args: &[&str]| {
1801 let status = std::process::Command::new("git")
1802 .args(["-C", &workspace]).args(args).status().unwrap();
1803 assert!(status.success(), "git {args:?} failed");
1804 };
1805 run(&["init", "-q"]);
1806 run(&["config", "user.email", "test@example.com"]);
1807 run(&["config", "user.name", "Test"]);
1808 run(&["commit", "--allow-empty", "-q", "-m", "init"]);
1809 run(&["checkout", "-q", "-b", branch]);
1810 for (name, url) in remotes {
1811 run(&["remote", "add", name, url]);
1812 }
1813 dir
1814 }
1815
1816 fn github_engine(store: std::sync::Arc<crate::store::Store>, gh: std::sync::Arc<FakeGithub>) -> std::sync::Arc<Engine> {
1817 Engine::new_with_github_api(store, gh as std::sync::Arc<dyn crate::github::GithubApi>)
1818 }
1819
1820 #[tokio::test]
1825 async fn poll_pr_reconciliation_adopts_pr_found_without_wrapper_metadata() {
1826 use crate::store::Store;
1827
1828 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
1829 let workspace = repo_dir.path().to_string_lossy().to_string();
1830
1831 let fake = std::sync::Arc::new(FakeGithub::default());
1832 fake.branch_matches.lock().unwrap().insert(
1833 ("Owner".to_string(), "repo".to_string(), "worker-branch".to_string()),
1834 crate::github::PrRef { number: 77, url: "https://github.com/Owner/repo/pull/77".into() },
1835 );
1836
1837 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1838 store.upsert_session(&test_session("s1", &workspace)).unwrap();
1839 let engine = github_engine(store.clone(), fake);
1840 let poller = Poller::new(engine);
1841
1842 poller.poll_pr_reconciliation().await;
1843
1844 let session = store.get_session("s1").unwrap().unwrap();
1845 assert_eq!(session.pr_number, Some(77), "no wrapper metadata was ever written — reconciliation must still find it");
1846 assert!(matches!(session.status, SessionStatus::PrOpen));
1847 assert_eq!(session.repo, "Owner/repo");
1848 }
1849
1850 #[tokio::test]
1856 async fn poll_pr_reconciliation_does_not_revert_mid_poll_statusline_fields() {
1857 use crate::store::Store;
1858
1859 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
1860 let workspace = repo_dir.path().to_string_lossy().to_string();
1861
1862 let fake = std::sync::Arc::new(FakeGithub::default());
1863 fake.branch_matches.lock().unwrap().insert(
1864 ("Owner".to_string(), "repo".to_string(), "worker-branch".to_string()),
1865 crate::github::PrRef { number: 77, url: "https://github.com/Owner/repo/pull/77".into() },
1866 );
1867
1868 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1869 let session = test_session("s1", &workspace);
1870 store.upsert_session(&session).unwrap();
1871
1872 let mut mid_poll = session.clone();
1873 mid_poll.cost_usd = 7.25;
1874 mid_poll.context_used_pct = Some(55.0);
1875 *fake.mid_branch_lookup_upsert.lock().unwrap() = Some((store.clone(), mid_poll));
1876
1877 let engine = github_engine(store.clone(), fake);
1878 let poller = Poller::new(engine);
1879
1880 poller.poll_pr_reconciliation().await;
1881
1882 let updated = store.get_session("s1").unwrap().unwrap();
1883 assert_eq!(updated.pr_number, Some(77), "the adoption write itself must still land");
1884 assert!(matches!(updated.status, SessionStatus::PrOpen));
1885 assert_eq!(
1886 updated.cost_usd, 7.25,
1887 "adoption must not revert cost_usd written mid-poll by the statusline process",
1888 );
1889 assert_eq!(updated.context_used_pct, Some(55.0), "mid-poll context fields must survive too");
1890 }
1891
1892 #[tokio::test]
1893 async fn poll_pr_reconciliation_leaves_sessions_with_no_matching_pr_untouched() {
1894 use crate::store::Store;
1895
1896 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
1897 let workspace = repo_dir.path().to_string_lossy().to_string();
1898
1899 let fake = std::sync::Arc::new(FakeGithub::default()); let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1902 store.upsert_session(&test_session("s1", &workspace)).unwrap();
1903 let engine = github_engine(store.clone(), fake);
1904 let poller = Poller::new(engine);
1905
1906 poller.poll_pr_reconciliation().await;
1907
1908 let session = store.get_session("s1").unwrap().unwrap();
1909 assert_eq!(session.pr_number, None);
1910 }
1911
1912 #[tokio::test]
1922 async fn poll_github_falls_back_to_other_remote_when_recorded_repo_404s() {
1923 use crate::store::Store;
1924
1925 let repo_dir = init_git_repo("worker-branch", &[
1926 ("origin", "https://github.com/OwnerA/repoA.git"),
1927 ("mirror", "https://github.com/OwnerB/repoB.git"),
1928 ]);
1929 let workspace = repo_dir.path().to_string_lossy().to_string();
1930
1931 let fake = std::sync::Arc::new(FakeGithub::default());
1932 fake.branch_matches.lock().unwrap().insert(
1933 ("OwnerB".to_string(), "repoB".to_string(), "worker-branch".to_string()),
1934 crate::github::PrRef { number: 99, url: "https://github.com/OwnerB/repoB/pull/99".into() },
1935 );
1936 fake.pr_status_ok.lock().unwrap().insert(
1937 ("OwnerB".to_string(), "repoB".to_string(), 99),
1938 crate::github::PrStatus {
1939 merged: false, state: "open".into(), mergeable: Some(true),
1940 title: "t".into(), number: 99, head_sha: "abc".into(),
1941 },
1942 );
1943 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1946 let mut session = test_session("s1", &workspace);
1947 session.repo = "OwnerA/repoA".into();
1948 session.pr_number = Some(50);
1949 store.upsert_session(&session).unwrap();
1950
1951 let engine = github_engine(store.clone(), fake.clone());
1952 let poller = Poller::new(engine);
1953
1954 poller.poll_github().await;
1955
1956 let updated = store.get_session("s1").unwrap().unwrap();
1957 assert_eq!(updated.repo, "OwnerB/repoB", "session.repo must self-heal to the remote that actually has the PR");
1958 assert_eq!(updated.pr_number, Some(99), "must adopt the mirror's own PR number, not reuse the tracked repo's number");
1959
1960 let calls = fake.calls.lock().unwrap().clone();
1961 assert_eq!(
1962 calls,
1963 vec![("OwnerA".to_string(), "repoA".to_string(), 50), ("OwnerB".to_string(), "repoB".to_string(), 99)],
1964 "the repo on record must be tried first (by its own number), the mirror only as a branch-matched fallback",
1965 );
1966 }
1967
1968 #[tokio::test]
1977 async fn poll_github_does_not_adopt_an_unrelated_pr_that_shares_the_tracked_number() {
1978 use crate::store::Store;
1979
1980 let repo_dir = init_git_repo("worker-branch", &[
1981 ("origin", "https://github.com/OwnerA/repoA.git"),
1982 ("mirror", "https://github.com/OwnerB/repoB.git"),
1983 ]);
1984 let workspace = repo_dir.path().to_string_lossy().to_string();
1985
1986 let fake = std::sync::Arc::new(FakeGithub::default());
1987 fake.pr_status_ok.lock().unwrap().insert(
1990 ("OwnerB".to_string(), "repoB".to_string(), 50),
1991 crate::github::PrStatus {
1992 merged: false, state: "open".into(), mergeable: Some(true),
1993 title: "someone else's unrelated PR".into(), number: 50, head_sha: "zzz".into(),
1994 },
1995 );
1996 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1999 let mut session = test_session("s1", &workspace);
2000 session.repo = "OwnerA/repoA".into();
2001 session.pr_number = Some(50);
2002 store.upsert_session(&session).unwrap();
2003
2004 let engine = github_engine(store.clone(), fake.clone());
2005 let poller = Poller::new(engine);
2006
2007 poller.poll_github().await;
2008
2009 let updated = store.get_session("s1").unwrap().unwrap();
2010 assert_eq!(updated.repo, "OwnerA/repoA", "must not adopt the mirror just because it happens to have a same-numbered PR");
2011 assert_eq!(updated.pr_number, Some(50));
2012
2013 let calls = fake.calls.lock().unwrap().clone();
2014 assert!(
2015 !calls.contains(&("OwnerB".to_string(), "repoB".to_string(), 50)),
2016 "must never retry the tracked number against another repo's get_pr_status: {calls:?}",
2017 );
2018 }
2019
2020 #[tokio::test]
2032 async fn poll_github_persists_pr_id_so_later_polls_dont_regress_it() {
2033 use crate::store::Store;
2034
2035 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
2036 let workspace = repo_dir.path().to_string_lossy().to_string();
2037
2038 let fake = std::sync::Arc::new(FakeGithub::default());
2039 fake.pr_status_ok.lock().unwrap().insert(
2040 ("Owner".to_string(), "repo".to_string(), 50),
2041 crate::github::PrStatus {
2042 merged: false, state: "open".into(), mergeable: Some(true),
2043 title: "t".into(), number: 50, head_sha: "abc".into(),
2044 },
2045 );
2046
2047 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2048 let mut session = test_session("s1", &workspace);
2049 session.repo = "Owner/repo".into();
2050 session.pr_number = Some(50);
2051 store.upsert_session(&session).unwrap();
2052
2053 let engine = github_engine(store.clone(), fake);
2054 let poller = Poller::new(engine);
2055
2056 poller.poll_github().await;
2057
2058 let updated = store.get_session("s1").unwrap().unwrap();
2059 assert_eq!(
2060 updated.pr_id, Some(50),
2061 "pr_id discovered by poll_github must be persisted to the store, or a later \
2062 read-modify-write by any other poller will re-broadcast pr_id: None and flicker the UI",
2063 );
2064 }
2065
2066 #[tokio::test]
2074 async fn poll_github_does_not_recompute_gate_for_terminated_session_with_unresolved_pr() {
2075 use crate::store::Store;
2076
2077 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
2078 let workspace = repo_dir.path().to_string_lossy().to_string();
2079
2080 let fake = std::sync::Arc::new(FakeGithub::default());
2081 fake.pr_status_ok.lock().unwrap().insert(
2082 ("Owner".to_string(), "repo".to_string(), 50),
2083 crate::github::PrStatus {
2084 merged: false, state: "open".into(), mergeable: Some(true),
2090 title: "t".into(), number: 50, head_sha: "abc".into(),
2091 },
2092 );
2093
2094 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2095 let mut session = test_session("s1", &workspace);
2096 session.repo = "Owner/repo".into();
2097 session.pr_number = Some(50);
2098 session.status = SessionStatus::Terminated;
2099 let stale_gate = GateStatus {
2100 ci: GateCheck::Failing, review: GateCheck::Failing,
2101 mergeable: GateCheck::Failing, since: 111,
2102 };
2103 session.gate_status = Some(stale_gate.clone());
2104 store.upsert_session(&session).unwrap();
2105
2106 let engine = github_engine(store.clone(), fake);
2107 let poller = Poller::new(engine);
2108
2109 poller.poll_github().await;
2110
2111 let updated = store.get_session("s1").unwrap().unwrap();
2112 assert!(
2113 matches!(updated.status, SessionStatus::Terminated),
2114 "status must stay Terminated (derive_session_status's own terminal guard)",
2115 );
2116 assert_eq!(
2117 updated.gate_status, Some(stale_gate),
2118 "gate_status must be carried forward unchanged for a terminal session, \
2119 even though live CI/mergeable data disagrees with it",
2120 );
2121 }
2122
2123 #[tokio::test]
2136 async fn poll_github_persists_pr_id_when_the_self_healed_pr_is_already_merged() {
2137 use crate::store::Store;
2138
2139 let repo_dir = init_git_repo("worker-branch", &[
2140 ("origin", "https://github.com/OwnerA/repoA.git"),
2141 ("mirror", "https://github.com/OwnerB/repoB.git"),
2142 ]);
2143 let workspace = repo_dir.path().to_string_lossy().to_string();
2144
2145 let fake = std::sync::Arc::new(FakeGithub::default());
2146 fake.branch_matches.lock().unwrap().insert(
2147 ("OwnerB".to_string(), "repoB".to_string(), "worker-branch".to_string()),
2148 crate::github::PrRef { number: 99, url: "https://github.com/OwnerB/repoB/pull/99".into() },
2149 );
2150 fake.pr_status_ok.lock().unwrap().insert(
2151 ("OwnerB".to_string(), "repoB".to_string(), 99),
2152 crate::github::PrStatus {
2153 merged: true, state: "closed".into(), mergeable: None,
2154 title: "t".into(), number: 99, head_sha: "abc".into(),
2155 },
2156 );
2157 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2161 let mut session = test_session("s1", &workspace);
2162 session.repo = "OwnerA/repoA".into();
2163 session.pr_number = Some(50);
2164 store.upsert_session(&session).unwrap();
2165
2166 let engine = github_engine(store.clone(), fake);
2167 let poller = Poller::new(engine);
2168
2169 poller.poll_github().await;
2170
2171 let updated = store.get_session("s1").unwrap().unwrap();
2172 assert_eq!(updated.repo, "OwnerB/repoB", "self-heal must still land");
2173 assert_eq!(updated.pr_number, Some(99), "self-heal must still land");
2174 assert_eq!(
2175 updated.pr_id, Some(99),
2176 "pr_id must be persisted even though the self-healed PR is already merged and \
2177 merge detection short-circuits the rest of this loop iteration",
2178 );
2179 assert!(matches!(updated.status, SessionStatus::Done), "merged PR still transitions to Done");
2180 }
2181
2182 #[tokio::test]
2192 async fn poll_github_status_write_does_not_revert_mid_poll_statusline_fields() {
2193 use crate::store::Store;
2194
2195 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
2196 let workspace = repo_dir.path().to_string_lossy().to_string();
2197
2198 let fake = std::sync::Arc::new(FakeGithub::default());
2199 fake.pr_status_ok.lock().unwrap().insert(
2200 ("Owner".to_string(), "repo".to_string(), 50),
2201 crate::github::PrStatus {
2202 merged: false, state: "open".into(), mergeable: Some(true),
2203 title: "t".into(), number: 50, head_sha: "abc".into(),
2204 },
2205 );
2206
2207 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2208 let mut session = test_session("s1", &workspace);
2209 session.repo = "Owner/repo".into();
2210 session.pr_number = Some(50);
2211 session.pr_id = Some(50); store.upsert_session(&session).unwrap();
2213
2214 let mut mid_poll = session.clone();
2216 mid_poll.cost_usd = 7.25;
2217 mid_poll.context_used_pct = Some(55.0);
2218 mid_poll.context_tokens = Some(123);
2219 *fake.mid_review_upsert.lock().unwrap() = Some((store.clone(), mid_poll));
2220
2221 let engine = github_engine(store.clone(), fake);
2222 let poller = Poller::new(engine);
2223
2224 poller.poll_github().await;
2225
2226 let updated = store.get_session("s1").unwrap().unwrap();
2227 assert!(
2228 matches!(updated.status, SessionStatus::Mergeable),
2229 "the status write itself must still land (open PR, all-green signals): {:?}",
2230 updated.status,
2231 );
2232 assert!(updated.gate_status.is_some(), "the gate write must still land");
2233 assert_eq!(
2234 updated.cost_usd, 7.25,
2235 "persisting status/gate must not revert cost_usd written mid-poll by the statusline process",
2236 );
2237 assert_eq!(updated.context_used_pct, Some(55.0), "mid-poll context fields must survive too");
2238 assert_eq!(updated.context_tokens, Some(123));
2239 }
2240
2241 #[tokio::test]
2247 async fn poll_github_self_heal_write_does_not_revert_mid_poll_statusline_fields() {
2248 use crate::store::Store;
2249
2250 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
2251 let workspace = repo_dir.path().to_string_lossy().to_string();
2252
2253 let fake = std::sync::Arc::new(FakeGithub::default());
2254 fake.pr_status_ok.lock().unwrap().insert(
2255 ("Owner".to_string(), "repo".to_string(), 50),
2256 crate::github::PrStatus {
2257 merged: false, state: "open".into(), mergeable: Some(true),
2258 title: "t".into(), number: 50, head_sha: "abc".into(),
2259 },
2260 );
2261
2262 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2263 let mut session = test_session("s1", &workspace);
2264 session.repo = "Owner/repo".into();
2265 session.pr_number = Some(50);
2266 session.pr_id = None; store.upsert_session(&session).unwrap();
2268
2269 let mut mid_poll = session.clone();
2270 mid_poll.cost_usd = 7.25;
2271 mid_poll.context_used_pct = Some(55.0);
2272 *fake.mid_pr_status_upsert.lock().unwrap() = Some((store.clone(), mid_poll));
2273
2274 let engine = github_engine(store.clone(), fake);
2275 let poller = Poller::new(engine);
2276
2277 poller.poll_github().await;
2278
2279 let updated = store.get_session("s1").unwrap().unwrap();
2280 assert_eq!(updated.pr_id, Some(50), "the self-heal write itself must still land");
2281 assert_eq!(
2282 updated.cost_usd, 7.25,
2283 "the self-heal write must not revert cost_usd written mid-poll by the statusline process",
2284 );
2285 assert_eq!(updated.context_used_pct, Some(55.0), "mid-poll context fields must survive too");
2286 }
2287
2288 #[tokio::test]
2292 async fn poll_github_status_write_does_not_resurrect_a_row_deleted_mid_poll() {
2293 use crate::store::Store;
2294
2295 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
2296 let workspace = repo_dir.path().to_string_lossy().to_string();
2297
2298 let fake = std::sync::Arc::new(FakeGithub::default());
2299 fake.pr_status_ok.lock().unwrap().insert(
2300 ("Owner".to_string(), "repo".to_string(), 50),
2301 crate::github::PrStatus {
2302 merged: false, state: "open".into(), mergeable: Some(true),
2303 title: "t".into(), number: 50, head_sha: "abc".into(),
2304 },
2305 );
2306
2307 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2308 let mut session = test_session("s1", &workspace);
2309 session.repo = "Owner/repo".into();
2310 session.pr_number = Some(50);
2311 session.pr_id = Some(50); store.upsert_session(&session).unwrap();
2313
2314 *fake.mid_review_delete.lock().unwrap() = Some((store.clone(), "s1".to_string()));
2315
2316 let engine = github_engine(store.clone(), fake);
2317 let poller = Poller::new(engine);
2318
2319 poller.poll_github().await;
2320
2321 assert!(
2322 store.get_session("s1").unwrap().is_none(),
2323 "a session deleted mid-poll must not be re-inserted by the status/gate write",
2324 );
2325 }
2326
2327 #[tokio::test]
2334 async fn poll_github_status_gate_emit_flags_exactly_status_and_gate() {
2335 use crate::store::Store;
2336
2337 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
2338 let workspace = repo_dir.path().to_string_lossy().to_string();
2339
2340 let fake = std::sync::Arc::new(FakeGithub::default());
2341 fake.pr_status_ok.lock().unwrap().insert(
2342 ("Owner".to_string(), "repo".to_string(), 50),
2343 crate::github::PrStatus {
2344 merged: false, state: "open".into(), mergeable: Some(true),
2345 title: "t".into(), number: 50, head_sha: "abc".into(),
2346 },
2347 );
2348
2349 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2350 let mut session = test_session("s1", &workspace);
2351 session.repo = "Owner/repo".into();
2352 session.pr_number = Some(50);
2353 session.pr_id = Some(50); store.upsert_session(&session).unwrap();
2355
2356 let engine = github_engine(store.clone(), fake);
2357 let mut rx = engine.subscribe();
2358 let poller = Poller::new(engine);
2359
2360 poller.poll_github().await;
2361
2362 let events = drain_events(&mut rx);
2363 let fields: Vec<SessionFields> = events.iter().filter_map(|e| match e {
2364 Event::SessionUpdated(s, f) if s.id == "s1" => Some(*f),
2365 _ => None,
2366 }).collect();
2367 assert_eq!(
2368 fields, vec![SessionFields::STATUS | SessionFields::GATE],
2369 "with nothing to self-heal, the tick's only SessionUpdated is the status/gate \
2370 write, flagged for exactly the two fields it persisted — no PR_LINK",
2371 );
2372 }
2373
2374 #[tokio::test]
2380 async fn poll_github_self_heal_emits_pr_link_flagged_update() {
2381 use crate::store::Store;
2382
2383 let repo_dir = init_git_repo("worker-branch", &[
2384 ("origin", "https://github.com/OwnerA/repoA.git"),
2385 ("mirror", "https://github.com/OwnerB/repoB.git"),
2386 ]);
2387 let workspace = repo_dir.path().to_string_lossy().to_string();
2388
2389 let fake = std::sync::Arc::new(FakeGithub::default());
2390 fake.branch_matches.lock().unwrap().insert(
2391 ("OwnerB".to_string(), "repoB".to_string(), "worker-branch".to_string()),
2392 crate::github::PrRef { number: 99, url: "https://github.com/OwnerB/repoB/pull/99".into() },
2393 );
2394 fake.pr_status_ok.lock().unwrap().insert(
2395 ("OwnerB".to_string(), "repoB".to_string(), 99),
2396 crate::github::PrStatus {
2397 merged: false, state: "open".into(), mergeable: Some(true),
2398 title: "t".into(), number: 99, head_sha: "abc".into(),
2399 },
2400 );
2401 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2405 let mut session = test_session("s1", &workspace);
2406 session.repo = "OwnerA/repoA".into();
2407 session.pr_number = Some(50);
2408 store.upsert_session(&session).unwrap();
2409
2410 let engine = github_engine(store.clone(), fake);
2411 let mut rx = engine.subscribe();
2412 let poller = Poller::new(engine);
2413
2414 poller.poll_github().await;
2415
2416 let events = drain_events(&mut rx);
2417 let healed = events.iter().find_map(|e| match e {
2418 Event::SessionUpdated(s, f) if *f == SessionFields::PR_LINK => Some(s.clone()),
2419 _ => None,
2420 }).expect(
2421 "self-heal must emit its own PR_LINK-flagged SessionUpdated — it's the only \
2422 channel by which the corrected pr fields reach the GUI",
2423 );
2424 assert_eq!(healed.repo, "OwnerB/repoB");
2425 assert_eq!(healed.pr_number, Some(99));
2426 assert_eq!(healed.pr_id, Some(99));
2427
2428 for e in &events {
2429 if let Event::SessionUpdated(_, f) = e {
2430 if f.contains(SessionFields::STATUS) {
2431 assert!(
2432 !f.contains(SessionFields::PR_LINK),
2433 "the status/gate emit must not over-flag PR_LINK on the self-heal's behalf",
2434 );
2435 }
2436 }
2437 }
2438 }
2439
2440 #[tokio::test]
2444 async fn poll_github_notifies_once_when_every_remote_fails_and_rearms_after_recovery() {
2445 use crate::store::Store;
2446
2447 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/OwnerA/repoA.git")]);
2448 let workspace = repo_dir.path().to_string_lossy().to_string();
2449
2450 let fake = std::sync::Arc::new(FakeGithub::default()); let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2453 let mut session = test_session("s1", &workspace);
2454 session.repo = "OwnerA/repoA".into();
2455 session.pr_number = Some(50);
2456 store.upsert_session(&session).unwrap();
2457
2458 let engine = github_engine(store.clone(), fake.clone());
2459 let mut rx = engine.subscribe();
2460 let poller = Poller::new(engine);
2461
2462 poller.poll_github().await;
2463 let events = drain_events(&mut rx);
2464 let failures = |evs: &[Event]| evs.iter().filter(|e| matches!(
2465 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::GithubLookupFailed
2466 )).count();
2467 assert_eq!(failures(&events), 1, "first failure must notify");
2468
2469 poller.poll_github().await;
2470 let events = drain_events(&mut rx);
2471 assert_eq!(failures(&events), 0, "repeated failure must not re-notify");
2472
2473 fake.pr_status_ok.lock().unwrap().insert(
2475 ("OwnerA".to_string(), "repoA".to_string(), 50),
2476 crate::github::PrStatus {
2477 merged: false, state: "open".into(), mergeable: Some(true),
2478 title: "t".into(), number: 50, head_sha: "abc".into(),
2479 },
2480 );
2481 poller.poll_github().await;
2482
2483 fake.pr_status_ok.lock().unwrap().clear();
2485 poller.poll_github().await;
2486 let events = drain_events(&mut rx);
2487 assert_eq!(failures(&events), 1, "must notify again after recovering and failing anew");
2488 }
2489
2490 struct FakeUpdateSource(std::sync::Mutex<Option<semver::Version>>);
2496
2497 #[async_trait::async_trait]
2498 impl UpdateSource for FakeUpdateSource {
2499 async fn latest_version(&self, _package: &str) -> anyhow::Result<Option<semver::Version>> {
2500 Ok(self.0.lock().unwrap().clone())
2501 }
2502 }
2503
2504 fn bare_engine() -> Arc<Engine> {
2505 use crate::store::Store;
2506 let store = Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2507 Engine::new(store)
2508 }
2509
2510 fn update_events(evs: &[Event]) -> usize {
2511 evs.iter().filter(|e| matches!(
2512 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::UpdateAvailable
2513 )).count()
2514 }
2515
2516 #[tokio::test]
2517 async fn poll_update_check_notifies_once_then_again_for_a_newer_version() {
2518 let engine = bare_engine();
2519 let mut rx = engine.subscribe();
2520 let source = Arc::new(FakeUpdateSource(std::sync::Mutex::new(Some(semver::Version::new(99, 0, 0)))));
2521 let poller = Poller::new(engine).with_update_source(source.clone());
2522
2523 poller.poll_update_check().await;
2524 assert_eq!(update_events(&drain_events(&mut rx)), 1, "first sighting of a newer version must notify");
2525
2526 poller.poll_update_check().await;
2527 assert_eq!(update_events(&drain_events(&mut rx)), 0, "same version again must not re-notify");
2528
2529 *source.0.lock().unwrap() = Some(semver::Version::new(100, 0, 0));
2530 poller.poll_update_check().await;
2531 assert_eq!(update_events(&drain_events(&mut rx)), 1, "an even newer version must notify again");
2532 }
2533
2534 #[tokio::test]
2535 async fn poll_update_check_no_notification_when_already_current() {
2536 let engine = bare_engine();
2537 let mut rx = engine.subscribe();
2538 let current: semver::Version = env!("CARGO_PKG_VERSION").parse().unwrap();
2539 let source = Arc::new(FakeUpdateSource(std::sync::Mutex::new(Some(current))));
2540 let poller = Poller::new(engine).with_update_source(source);
2541
2542 poller.poll_update_check().await;
2543 assert_eq!(update_events(&drain_events(&mut rx)), 0);
2544 }
2545
2546 #[tokio::test]
2547 async fn poll_update_check_no_notification_when_source_has_nothing() {
2548 let engine = bare_engine();
2549 let mut rx = engine.subscribe();
2550 let source = Arc::new(FakeUpdateSource(std::sync::Mutex::new(None)));
2551 let poller = Poller::new(engine).with_update_source(source);
2552
2553 poller.poll_update_check().await;
2554 assert_eq!(update_events(&drain_events(&mut rx)), 0);
2555 }
2556
2557 use std::{future::Future, pin::Pin};
2560
2561 struct FakeHarvestRunner {
2565 calls: tokio::sync::mpsc::UnboundedSender<(String, PathBuf, PathBuf)>,
2566 outcome: Result<(), String>,
2567 }
2568
2569 impl HarvestRunner for FakeHarvestRunner {
2570 fn run(
2571 &self,
2572 prompt: String,
2573 workspace: PathBuf,
2574 brain_path: PathBuf,
2575 ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>> {
2576 let _ = self.calls.send((prompt, workspace, brain_path));
2577 let outcome = self.outcome.clone();
2578 Box::pin(async move {
2579 outcome.map_err(|e| anyhow::anyhow!(e))
2580 })
2581 }
2582 }
2583
2584 fn init_diff_repo(feature_branch: &str, extra_file: Option<(&str, &str)>) -> std::path::PathBuf {
2589 let dir = tempfile::tempdir().unwrap().keep();
2590 let run = |args: &[&str]| {
2591 std::process::Command::new("git")
2592 .args(["-C", dir.to_str().unwrap()])
2593 .args(args)
2594 .output()
2595 .unwrap()
2596 };
2597 run(&["init", "-q", "-b", "main"]);
2598 run(&["config", "user.email", "test@example.com"]);
2599 run(&["config", "user.name", "Test"]);
2600 std::fs::write(dir.join("README.md"), "x").unwrap();
2601 run(&["add", "."]);
2602 run(&["commit", "-q", "-m", "init"]);
2603 run(&["checkout", "-q", "-b", feature_branch]);
2604 if let Some((name, contents)) = extra_file {
2605 std::fs::write(dir.join(name), contents).unwrap();
2606 run(&["add", name]);
2607 run(&["commit", "-q", "-m", "feature work"]);
2608 }
2609 dir
2610 }
2611
2612 fn nonexistent_config_path() -> std::path::PathBuf {
2616 tempfile::tempdir().unwrap().keep().join("nonexistent-ninox-config.toml")
2617 }
2618
2619 #[allow(clippy::await_holding_lock)]
2624 #[tokio::test]
2625 async fn metadata_sync_triggers_brain_harvest_exactly_once_on_pr_detection() {
2626 use crate::{config::ENV_TEST_GUARD, store::Store};
2627
2628 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2629 let prior = std::env::var("NINOX_CONFIG").ok();
2630 std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
2631
2632 let repo = init_diff_repo("feature-1", Some(("src.rs", "fn main() {}\n")));
2633 let workspace = repo.to_str().unwrap().to_string();
2634
2635 let sessions_dir = tempfile::tempdir().unwrap();
2636 let meta = serde_json::json!({"agentReportedPrNumber": "9"});
2637 std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
2638
2639 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2640 store.upsert_session(&test_session("s1", &workspace)).unwrap();
2641 let engine = Engine::new(store.clone());
2642
2643 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2644 let runner = Arc::new(FakeHarvestRunner { calls: tx, outcome: Ok(()) });
2645 let poller = Poller::new_with_harvest_runner(engine, runner);
2646
2647 poller.sync_sessions_metadata(sessions_dir.path()).await;
2648
2649 let (prompt, ..) = tokio::time::timeout(Duration::from_secs(2), rx.recv())
2650 .await
2651 .expect("harvest should be attempted")
2652 .expect("channel should not be closed");
2653 assert!(prompt.contains("src.rs") && prompt.contains("fn main"), "prompt must include the diff");
2654
2655 poller.sync_sessions_metadata(sessions_dir.path()).await;
2658 assert!(rx.try_recv().is_err(), "harvest must fire exactly once per session");
2659
2660 match prior {
2661 Some(v) => std::env::set_var("NINOX_CONFIG", v),
2662 None => std::env::remove_var("NINOX_CONFIG"),
2663 }
2664 }
2665
2666 #[allow(clippy::await_holding_lock)]
2671 #[tokio::test]
2672 async fn metadata_sync_brain_harvest_prefers_session_catalogue_path_over_default() {
2673 use crate::{config::ENV_TEST_GUARD, store::Store};
2674
2675 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2676 let prior = std::env::var("NINOX_CONFIG").ok();
2677 std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
2678
2679 let repo = init_diff_repo("feature-catalogue", Some(("src.rs", "fn main() {}\n")));
2680 let workspace = repo.to_str().unwrap().to_string();
2681
2682 let sessions_dir = tempfile::tempdir().unwrap();
2683 let meta = serde_json::json!({"agentReportedPrNumber": "13"});
2684 std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
2685
2686 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2687 let mut session = test_session("s1", &workspace);
2688 session.catalogue_path = Some("/custom/brain-catalogue".to_string());
2689 store.upsert_session(&session).unwrap();
2690 let engine = Engine::new(store.clone());
2691
2692 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2693 let runner = Arc::new(FakeHarvestRunner { calls: tx, outcome: Ok(()) });
2694 let poller = Poller::new_with_harvest_runner(engine, runner);
2695
2696 poller.sync_sessions_metadata(sessions_dir.path()).await;
2697
2698 let (_, _, brain_path) = tokio::time::timeout(Duration::from_secs(2), rx.recv())
2699 .await
2700 .expect("harvest should be attempted")
2701 .expect("channel should not be closed");
2702 assert_eq!(
2703 brain_path, PathBuf::from("/custom/brain-catalogue"),
2704 "harvest must target the session's own catalogue, not the global default",
2705 );
2706
2707 match prior {
2708 Some(v) => std::env::set_var("NINOX_CONFIG", v),
2709 None => std::env::remove_var("NINOX_CONFIG"),
2710 }
2711 }
2712
2713 #[allow(clippy::await_holding_lock)]
2716 #[tokio::test]
2717 async fn metadata_sync_skips_brain_harvest_when_disabled() {
2718 use crate::{config::ENV_TEST_GUARD, store::Store};
2719
2720 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2721 let config_dir = tempfile::tempdir().unwrap();
2722 let config_path = config_dir.path().join("config.toml");
2723 std::fs::write(&config_path, "port = 8080\nfont_size = 13.0\n\n[brain_harvest]\nenabled = false\n").unwrap();
2724 let prior = std::env::var("NINOX_CONFIG").ok();
2725 std::env::set_var("NINOX_CONFIG", &config_path);
2726
2727 let repo = init_diff_repo("feature-2", Some(("src.rs", "fn main() {}\n")));
2728 let workspace = repo.to_str().unwrap().to_string();
2729
2730 let sessions_dir = tempfile::tempdir().unwrap();
2731 let meta = serde_json::json!({"agentReportedPrNumber": "10"});
2732 std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
2733
2734 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2735 store.upsert_session(&test_session("s1", &workspace)).unwrap();
2736 let engine = Engine::new(store.clone());
2737
2738 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2739 let runner = Arc::new(FakeHarvestRunner { calls: tx, outcome: Ok(()) });
2740 let poller = Poller::new_with_harvest_runner(engine, runner);
2741
2742 poller.sync_sessions_metadata(sessions_dir.path()).await;
2743
2744 assert!(rx.try_recv().is_err(), "harvest must not fire when brain_harvest.enabled = false");
2745 let session = store.get_session("s1").unwrap().unwrap();
2746 assert_eq!(session.pr_number, Some(10), "PR detection must be unaffected by the disabled harvest");
2747 assert!(matches!(session.status, SessionStatus::PrOpen));
2748
2749 match prior {
2750 Some(v) => std::env::set_var("NINOX_CONFIG", v),
2751 None => std::env::remove_var("NINOX_CONFIG"),
2752 }
2753 }
2754
2755 #[allow(clippy::await_holding_lock)]
2759 #[tokio::test]
2760 async fn metadata_sync_skips_brain_harvest_for_trivial_diff() {
2761 use crate::{config::ENV_TEST_GUARD, store::Store};
2762
2763 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2764 let prior = std::env::var("NINOX_CONFIG").ok();
2765 std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
2766
2767 let repo = init_diff_repo("feature-3", None);
2769 let workspace = repo.to_str().unwrap().to_string();
2770
2771 let sessions_dir = tempfile::tempdir().unwrap();
2772 let meta = serde_json::json!({"agentReportedPrNumber": "11"});
2773 std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
2774
2775 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2776 store.upsert_session(&test_session("s1", &workspace)).unwrap();
2777 let engine = Engine::new(store.clone());
2778
2779 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2780 let runner = Arc::new(FakeHarvestRunner { calls: tx, outcome: Ok(()) });
2781 let poller = Poller::new_with_harvest_runner(engine, runner);
2782
2783 poller.sync_sessions_metadata(sessions_dir.path()).await;
2784
2785 assert!(rx.try_recv().is_err(), "harvest must not fire for an empty diff");
2786
2787 match prior {
2788 Some(v) => std::env::set_var("NINOX_CONFIG", v),
2789 None => std::env::remove_var("NINOX_CONFIG"),
2790 }
2791 }
2792
2793 #[allow(clippy::await_holding_lock)]
2797 #[tokio::test]
2798 async fn metadata_sync_survives_a_failing_brain_harvest() {
2799 use crate::{config::ENV_TEST_GUARD, store::Store};
2800
2801 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2802 let prior = std::env::var("NINOX_CONFIG").ok();
2803 std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
2804
2805 let repo = init_diff_repo("feature-4", Some(("src.rs", "fn main() {}\n")));
2806 let workspace = repo.to_str().unwrap().to_string();
2807
2808 let sessions_dir = tempfile::tempdir().unwrap();
2809 let meta = serde_json::json!({"agentReportedPrNumber": "12"});
2810 std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
2811
2812 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2813 store.upsert_session(&test_session("s1", &workspace)).unwrap();
2814 let engine = Engine::new(store.clone());
2815
2816 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2817 let runner = Arc::new(FakeHarvestRunner {
2818 calls: tx,
2819 outcome: Err("simulated claude -p failure".to_string()),
2820 });
2821 let poller = Poller::new_with_harvest_runner(engine, runner);
2822
2823 poller.sync_sessions_metadata(sessions_dir.path()).await;
2824
2825 let session = store.get_session("s1").unwrap().unwrap();
2826 assert_eq!(session.pr_number, Some(12), "PR detection must succeed regardless of harvest outcome");
2827 assert!(matches!(session.status, SessionStatus::PrOpen));
2828
2829 tokio::time::timeout(Duration::from_secs(2), rx.recv())
2830 .await
2831 .expect("the failing harvest must still be attempted")
2832 .expect("channel should not be closed");
2833
2834 match prior {
2835 Some(v) => std::env::set_var("NINOX_CONFIG", v),
2836 None => std::env::remove_var("NINOX_CONFIG"),
2837 }
2838 }
2839
2840 #[derive(Clone, Default)]
2843 struct CapturedLogs(Arc<std::sync::Mutex<Vec<String>>>);
2844
2845 impl CapturedLogs {
2846 fn contains(&self, needle: &str) -> bool {
2847 self.0.lock().unwrap().iter().any(|m| m.contains(needle))
2848 }
2849 }
2850
2851 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CapturedLogs {
2852 fn on_event(&self, event: &tracing::Event<'_>, _ctx: tracing_subscriber::layer::Context<'_, S>) {
2853 struct Visitor(String);
2854 impl tracing::field::Visit for Visitor {
2855 fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
2856 if field.name() == "message" {
2857 self.0 = format!("{value:?}");
2858 }
2859 }
2860 }
2861 let mut visitor = Visitor(String::new());
2862 event.record(&mut visitor);
2863 self.0.lock().unwrap().push(visitor.0);
2864 }
2865 }
2866
2867 struct PanickingHarvestRunner;
2871
2872 impl HarvestRunner for PanickingHarvestRunner {
2873 fn run(
2874 &self,
2875 _prompt: String,
2876 _workspace: PathBuf,
2877 _brain_path: PathBuf,
2878 ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>> {
2879 Box::pin(async move { panic!("simulated harvest panic") })
2880 }
2881 }
2882
2883 #[allow(clippy::await_holding_lock)]
2888 #[tokio::test]
2889 async fn metadata_sync_logs_a_warning_when_harvest_task_panics() {
2890 use crate::{config::ENV_TEST_GUARD, store::Store};
2891 use tracing_subscriber::{layer::SubscriberExt, Registry};
2892
2893 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2894 let prior = std::env::var("NINOX_CONFIG").ok();
2895 std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
2896
2897 let logs = CapturedLogs::default();
2898 let _log_guard = tracing::subscriber::set_default(Registry::default().with(logs.clone()));
2899
2900 let repo = init_diff_repo("feature-panic", Some(("src.rs", "fn main() {}\n")));
2901 let workspace = repo.to_str().unwrap().to_string();
2902
2903 let sessions_dir = tempfile::tempdir().unwrap();
2904 let meta = serde_json::json!({"agentReportedPrNumber": "20"});
2905 std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
2906
2907 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2908 store.upsert_session(&test_session("s1", &workspace)).unwrap();
2909 let engine = Engine::new(store.clone());
2910
2911 let poller = Poller::new_with_harvest_runner(engine, Arc::new(PanickingHarvestRunner));
2912 poller.sync_sessions_metadata(sessions_dir.path()).await;
2913
2914 let deadline = std::time::Instant::now() + Duration::from_secs(5);
2918 while !logs.contains("brain harvest task panicked") {
2919 assert!(std::time::Instant::now() < deadline, "timed out waiting for the panic to be logged");
2920 tokio::time::sleep(Duration::from_millis(20)).await;
2921 }
2922
2923 match prior {
2924 Some(v) => std::env::set_var("NINOX_CONFIG", v),
2925 None => std::env::remove_var("NINOX_CONFIG"),
2926 }
2927 }
2928
2929 struct OverlapDetectingHarvestRunner {
2934 calls: tokio::sync::mpsc::UnboundedSender<()>,
2935 active: Arc<std::sync::atomic::AtomicUsize>,
2936 overlapped: Arc<std::sync::atomic::AtomicBool>,
2937 }
2938
2939 impl HarvestRunner for OverlapDetectingHarvestRunner {
2940 fn run(
2941 &self,
2942 _prompt: String,
2943 _workspace: PathBuf,
2944 _brain_path: PathBuf,
2945 ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>> {
2946 let _ = self.calls.send(());
2947 let active = self.active.clone();
2948 let overlapped = self.overlapped.clone();
2949 Box::pin(async move {
2950 if active.fetch_add(1, std::sync::atomic::Ordering::SeqCst) > 0 {
2951 overlapped.store(true, std::sync::atomic::Ordering::SeqCst);
2952 }
2953 tokio::time::sleep(Duration::from_millis(50)).await;
2954 active.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
2955 Ok(())
2956 })
2957 }
2958 }
2959
2960 #[allow(clippy::await_holding_lock)]
2966 #[tokio::test]
2967 async fn concurrent_harvests_to_the_same_vault_do_not_overlap() {
2968 use crate::{config::ENV_TEST_GUARD, store::Store};
2969
2970 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2971 let prior = std::env::var("NINOX_CONFIG").ok();
2972 std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
2973
2974 let repo1 = init_diff_repo("feature-vault-1", Some(("a.rs", "fn a() {}\n")));
2975 let repo2 = init_diff_repo("feature-vault-2", Some(("b.rs", "fn b() {}\n")));
2976
2977 let sessions_dir = tempfile::tempdir().unwrap();
2978 for (id, pr) in [("s1", "30"), ("s2", "31")] {
2979 let meta = serde_json::json!({"agentReportedPrNumber": pr});
2980 std::fs::write(sessions_dir.path().join(format!("{id}.json")), serde_json::to_string(&meta).unwrap()).unwrap();
2981 }
2982
2983 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2984 store.upsert_session(&test_session("s1", repo1.to_str().unwrap())).unwrap();
2985 store.upsert_session(&test_session("s2", repo2.to_str().unwrap())).unwrap();
2986 let engine = Engine::new(store.clone());
2987
2988 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2989 let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2990 let overlapped = Arc::new(std::sync::atomic::AtomicBool::new(false));
2991 let runner = Arc::new(OverlapDetectingHarvestRunner {
2992 calls: tx, active: active.clone(), overlapped: overlapped.clone(),
2993 });
2994 let poller = Poller::new_with_harvest_runner(engine, runner);
2995
2996 poller.sync_sessions_metadata(sessions_dir.path()).await;
2997
2998 for _ in 0..2 {
2999 tokio::time::timeout(Duration::from_secs(2), rx.recv())
3000 .await
3001 .expect("both harvests should be attempted")
3002 .expect("channel should not be closed");
3003 }
3004 tokio::time::sleep(Duration::from_millis(150)).await;
3007
3008 assert!(
3009 !overlapped.load(std::sync::atomic::Ordering::SeqCst),
3010 "concurrent harvests to the same vault must not run HarvestRunner::run concurrently",
3011 );
3012
3013 match prior {
3014 Some(v) => std::env::set_var("NINOX_CONFIG", v),
3015 None => std::env::remove_var("NINOX_CONFIG"),
3016 }
3017 }
3018
3019 #[test]
3025 fn vault_lock_treats_equivalent_paths_as_the_same_vault() {
3026 use crate::store::Store;
3027
3028 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3029 let engine = Engine::new(store);
3030 let poller = Poller::new_with_harvest_runner(engine, Arc::new(ClaudeHarvestRunner));
3031
3032 let dir = tempfile::tempdir().unwrap();
3033 let canonical = dir.path().to_path_buf();
3034 let with_trailing_slash = PathBuf::from(format!("{}/", canonical.display()));
3035
3036 let lock_a = poller.vault_lock(&canonical);
3037 let lock_b = poller.vault_lock(&with_trailing_slash);
3038
3039 assert!(
3040 Arc::ptr_eq(&lock_a, &lock_b),
3041 "syntactically different paths to the same physical vault must share one lock",
3042 );
3043 }
3044 #[tokio::test]
3050 async fn merge_detection_fires_for_terminated_session() {
3051 use crate::store::Store;
3052
3053 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3054 let mut s = test_session("w1", "/ws");
3055 s.status = SessionStatus::Terminated;
3056 s.orchestrator_id = Some("orch1".into());
3057 s.pr_number = Some(42);
3058 store.upsert_session(&s).unwrap();
3059
3060 let engine = Engine::new(store.clone());
3061 let mut rx = engine.subscribe();
3062 let poller = Poller::new(engine);
3063
3064 let handled = poller.handle_merge_detection(&s, 42, true).await;
3065 assert!(handled, "merge detection must run for a Terminated session");
3066
3067 let after = store.get_session("w1").unwrap().unwrap();
3068 assert!(matches!(after.status, SessionStatus::Done), "merged session transitions to Done");
3069 assert!(after.terminal_at.is_some(), "Done via merge detection must stamp terminal_at");
3070
3071 let events = drain_events(&mut rx);
3072 let merged_notifs = events.iter().filter(|e| matches!(
3073 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkerDone
3074 )).count();
3075 assert_eq!(merged_notifs, 1, "exactly one WorkerDone notification for the merge");
3076 }
3077
3078 #[tokio::test]
3083 async fn merge_detection_does_not_fire_twice_for_the_same_session() {
3084 use crate::store::Store;
3085
3086 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3087 let mut s = test_session("w1", "/ws");
3088 s.status = SessionStatus::PrOpen;
3089 s.orchestrator_id = Some("orch1".into());
3090 s.pr_number = Some(7);
3091 store.upsert_session(&s).unwrap();
3092
3093 let engine = Engine::new(store.clone());
3094 let mut rx = engine.subscribe();
3095 let poller = Poller::new(engine);
3096
3097 let first = poller.handle_merge_detection(&s, 7, true).await;
3098 assert!(first, "first tick handles the merge");
3099
3100 let updated = store.get_session("w1").unwrap().unwrap();
3103 let second = poller.handle_merge_detection(&updated, 7, true).await;
3104 assert!(!second, "an already-Done session must not re-fire merge detection");
3105
3106 let events = drain_events(&mut rx);
3107 let merged_notifs = events.iter().filter(|e| matches!(
3108 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkerDone
3109 )).count();
3110 assert_eq!(merged_notifs, 1, "no duplicate WorkerDone notification across ticks");
3111 }
3112
3113 #[tokio::test]
3114 async fn handle_merge_detection_is_noop_when_pr_not_merged() {
3115 use crate::store::Store;
3116
3117 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3118 let s = test_session("w1", "/ws");
3119 store.upsert_session(&s).unwrap();
3120 let engine = Engine::new(store.clone());
3121 let poller = Poller::new(engine);
3122
3123 let handled = poller.handle_merge_detection(&s, 1, false).await;
3124 assert!(!handled);
3125 assert!(matches!(store.get_session("w1").unwrap().unwrap().status, SessionStatus::Working));
3126 }
3127
3128 #[tokio::test]
3135 async fn sweep_retired_sessions_purges_only_past_the_retention_window() {
3136 use crate::store::Store;
3137
3138 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3139 let retention = SessionRetentionConfig { done_retention_days: 2 };
3140 let now = now_millis();
3141 let retention_ms = retention.retention_millis();
3142
3143 let mut expired = test_session("expired", "/ws");
3144 expired.status = SessionStatus::Done;
3145 expired.terminal_at = Some(now - retention_ms - 1_000);
3146 store.upsert_session(&expired).unwrap();
3147
3148 let mut fresh = test_session("fresh", "/ws");
3149 fresh.status = SessionStatus::Done;
3150 fresh.terminal_at = Some(now - 1_000);
3151 store.upsert_session(&fresh).unwrap();
3152
3153 let engine = Engine::new(store.clone());
3154 let mut rx = engine.subscribe();
3155 let poller = Poller::new(engine);
3156
3157 poller.sweep_retired_sessions(&retention).await;
3158
3159 assert!(store.get_session("expired").unwrap().is_none(), "past-retention session must be purged");
3160 assert!(store.get_session("fresh").unwrap().is_some(), "within-retention session must survive");
3161
3162 let events = drain_events(&mut rx);
3163 assert!(events.iter().any(|e| matches!(e, Event::SessionDone(id) if id == "expired")));
3164 }
3165
3166 #[tokio::test]
3171 async fn sweep_retired_sessions_purges_sessions_with_no_terminal_at_immediately() {
3172 use crate::store::Store;
3173
3174 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3175 let mut s = test_session("user-killed", "/ws");
3176 s.status = SessionStatus::Terminated;
3177 s.terminal_at = None;
3178 store.upsert_session(&s).unwrap();
3179
3180 let engine = Engine::new(store.clone());
3181 let poller = Poller::new(engine);
3182
3183 poller.sweep_retired_sessions(&SessionRetentionConfig::default()).await;
3184
3185 assert!(store.get_session("user-killed").unwrap().is_none());
3186 }
3187
3188 #[tokio::test]
3191 async fn sweep_retired_sessions_never_purges_orchestrator_sessions() {
3192 use crate::store::Store;
3193
3194 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3195 store.upsert_orchestrator(&crate::types::Orchestrator {
3196 id: "orch1".into(), name: "orch".into(), created_at: 0,
3197 }).unwrap();
3198 let mut s = test_session("orch1", "/ws");
3199 s.status = SessionStatus::Done;
3200 s.terminal_at = Some(0); store.upsert_session(&s).unwrap();
3202
3203 let engine = Engine::new(store.clone());
3204 let poller = Poller::new(engine);
3205
3206 poller.sweep_retired_sessions(&SessionRetentionConfig::default()).await;
3207
3208 assert!(store.get_session("orch1").unwrap().is_some(), "orchestrator sessions are never auto-purged");
3209 }
3210
3211 #[tokio::test]
3214 async fn sweep_retired_sessions_ignores_non_terminal_sessions() {
3215 use crate::store::Store;
3216
3217 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3218 let mut s = test_session("still-working", "/ws");
3219 s.status = SessionStatus::Working;
3220 s.terminal_at = None;
3221 store.upsert_session(&s).unwrap();
3222
3223 let engine = Engine::new(store.clone());
3224 let poller = Poller::new(engine);
3225
3226 poller.sweep_retired_sessions(&SessionRetentionConfig::default()).await;
3227
3228 assert!(store.get_session("still-working").unwrap().is_some());
3229 }
3230
3231 #[tokio::test]
3237 async fn sweep_retired_sessions_notifies_orchestrator_for_never_merge_detected_session() {
3238 use crate::store::Store;
3239
3240 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3241 let mut s = test_session("w1", "/ws");
3242 s.status = SessionStatus::Terminated; s.orchestrator_id = Some("orch1".into());
3244 s.terminal_at = Some(0); store.upsert_session(&s).unwrap();
3246
3247 let engine = Engine::new(store.clone());
3248 let mut rx = engine.subscribe();
3249 let poller = Poller::new(engine);
3250
3251 poller.sweep_retired_sessions(&SessionRetentionConfig::default()).await;
3252
3253 assert!(store.get_session("w1").unwrap().is_none(), "session must still be purged");
3254
3255 let events = drain_events(&mut rx);
3256 let retired_notifs = events.iter().filter(|e| matches!(
3257 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkerRetired
3258 && n.id == "retired-w1"
3259 )).count();
3260 assert_eq!(retired_notifs, 1, "orchestrator must be told its worker was retired");
3261 }
3262
3263 #[tokio::test]
3268 async fn sweep_retired_sessions_does_not_double_notify_a_merge_detected_session() {
3269 use crate::store::Store;
3270
3271 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3272 let mut s = test_session("w1", "/ws");
3273 s.orchestrator_id = Some("orch1".into());
3274 s.pr_number = Some(7);
3275 store.upsert_session(&s).unwrap();
3276
3277 let engine = Engine::new(store.clone());
3278 let mut rx = engine.subscribe();
3279 let poller = Poller::new(engine);
3280
3281 assert!(poller.handle_merge_detection(&s, 7, true).await);
3284 drain_events(&mut rx); let mut done = store.get_session("w1").unwrap().unwrap();
3288 done.terminal_at = Some(0);
3289 store.upsert_session(&done).unwrap();
3290
3291 poller.sweep_retired_sessions(&SessionRetentionConfig::default()).await;
3292
3293 assert!(store.get_session("w1").unwrap().is_none(), "session must still be purged");
3294
3295 let events = drain_events(&mut rx);
3296 let retired_notifs = events.iter().filter(|e| matches!(
3297 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkerRetired
3298 )).count();
3299 assert_eq!(retired_notifs, 0, "must not re-notify a session already told about its merge");
3300 }
3301}