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 let issue_comments = match gh.get_issue_comments(&owner, &repo, pr_number).await {
725 Ok(c) => c,
726 Err(e) => { tracing::warn!("github issue comments: {e}"); vec![] }
727 };
728
729 let has_changes_requested = threads.iter().any(|t| t.state == "CHANGES_REQUESTED");
730
731 let (has_new, review_reaction_already_sent, new_comments) = {
732 let mut cache = self.enrichment_cache.lock().unwrap();
733 let state = cache.entry(session.id.clone()).or_default();
734 let mut has_new = false;
735 let mut new_comments: Vec<Comment> = Vec::new();
736
737 for thread in &threads {
750 let is_changes_requested = thread.state == "CHANGES_REQUESTED";
751 let is_displayable = is_changes_requested || thread.state == "COMMENTED";
752 if !is_displayable || state.seen_comment_ids.contains(&thread.id) {
753 continue;
754 }
755 if !is_changes_requested && thread.body.trim().is_empty() {
756 continue;
757 }
758 state.seen_comment_ids.insert(thread.id);
759 let comment = Comment {
760 id: thread.id,
761 pr_id,
762 author: thread.author.clone(),
763 body: thread.body.clone(),
764 path: thread.path.clone(),
765 line: thread.line,
766 created_at: thread.created_at,
767 };
768 let _ = self.engine.store.upsert_comment(&comment);
769 self.engine.emit(Event::ReviewComment { pr_id, comment: comment.clone() });
770 if is_changes_requested {
771 has_new = true;
772 new_comments.push(comment);
773 }
774 }
775
776 for issue_comment in &issue_comments {
777 if state.seen_comment_ids.contains(&issue_comment.id) {
778 continue;
779 }
780 state.seen_comment_ids.insert(issue_comment.id);
781 let comment = Comment { pr_id, ..issue_comment.clone() };
782 let _ = self.engine.store.upsert_comment(&comment);
783 self.engine.emit(Event::ReviewComment { pr_id, comment });
784 }
785
786 let already_sent = state.review_reaction_sent;
787 if has_new && !already_sent {
788 state.review_reaction_sent = true;
789 }
790 if !has_changes_requested {
792 state.review_reaction_sent = false;
793 }
794 (has_new, already_sent, new_comments)
795 };
796
797 let new_status = derive_session_status(&session.status, &pr_status, &ci, has_changes_requested);
799 let new_gate = compute_new_gate(
800 &session.status, &ci, has_changes_requested, pr_status.mergeable,
801 session.gate_status.as_ref(), now_millis(),
802 );
803 if new_status != session.status || new_gate != session.gate_status {
804 if let Some(updated) = self.update_live_session_row(&session, |row| {
809 row.status = new_status;
810 row.gate_status = new_gate;
811 }) {
812 self.engine.emit(Event::SessionUpdated(
813 updated,
814 SessionFields::STATUS | SessionFields::GATE,
815 ));
816 }
817 }
818
819 if has_new && !review_reaction_already_sent {
820 self.engine.emit(Event::Notification(Notification {
821 id: format!("review-{}", session.id),
822 kind: NotificationKind::PrNeedsAttention,
823 title: format!("Review comments — {}", session.name),
824 body: "Changes requested on your PR".to_string(),
825 session_id: Some(session.id.clone()),
826 created_at: now_millis(),
827 }));
828 if !new_comments.is_empty() {
829 let msg = crate::lifecycle::reactions::format_review_reaction(
830 &session, &new_comments
831 );
832 if let Err(e) = self.engine.send_to_session(&session.id, &msg).await {
833 tracing::warn!("send review reaction to {}: {e}", session.id);
834 }
835 }
836 }
837 }
838 }
839
840 fn notify_github_lookup_failed(&self, session: &crate::types::Session) {
844 let already_notified = {
845 let mut cache = self.enrichment_cache.lock().unwrap();
846 let state = cache.entry(session.id.clone()).or_default();
847 let already = state.github_lookup_failed_notified;
848 state.github_lookup_failed_notified = true;
849 already
850 };
851 if already_notified {
852 return;
853 }
854 self.engine.emit(Event::Notification(Notification {
855 id: format!("github-lookup-failed-{}", session.id),
856 kind: NotificationKind::GithubLookupFailed,
857 title: format!("GitHub lookup failing — {}", session.name),
858 body: "PR status/CI/review polling failed against every configured remote"
859 .to_string(),
860 session_id: Some(session.id.clone()),
861 created_at: now_millis(),
862 }));
863 }
864
865 fn clear_github_lookup_failed(&self, session_id: &str) {
868 let mut cache = self.enrichment_cache.lock().unwrap();
869 if let Some(state) = cache.get_mut(session_id) {
870 state.github_lookup_failed_notified = false;
871 }
872 }
873
874 async fn poll_pr_reconciliation(&self) {
885 let Some(gh) = &self.engine.github else { return };
886 let Ok(sessions) = self.engine.store.list_sessions() else { return };
887
888 for mut session in sessions {
889 if session.pr_number.is_some() {
890 continue;
891 }
892 if matches!(session.status, SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted) {
893 continue;
894 }
895 let Some(workspace) = session.workspace_path.clone() else { continue };
896 let Some(branch) = crate::github::current_branch(&workspace) else { continue };
897
898 for repo_slug in crate::github::candidate_repos(&workspace) {
899 let Some((owner, repo)) = split_repo(&repo_slug) else { continue };
900 match gh.find_open_pr_for_branch(&owner, &repo, &branch).await {
901 Ok(Some(pr_ref)) => {
902 session.pr_number = Some(pr_ref.number);
903 session.repo = repo_slug.clone();
904 session.status = SessionStatus::PrOpen;
905 if let Some(written) = self.update_live_session_row(&session, |row| {
910 row.pr_number = session.pr_number;
911 row.repo = session.repo.clone();
912 row.status = session.status.clone();
913 }) {
914 self.engine.emit(Event::SessionUpdated(
915 written, SessionFields::PR_LINK | SessionFields::STATUS,
916 ));
917 tracing::info!(
918 "session {} PR #{} detected via reconciliation ({repo_slug}, branch {branch})",
919 session.id, pr_ref.number,
920 );
921 }
922 break;
923 }
924 Ok(None) => continue,
925 Err(e) => {
926 tracing::warn!("reconciliation lookup {repo_slug} branch {branch}: {e}");
927 continue;
928 }
929 }
930 }
931 }
932 }
933 async fn handle_merge_detection(
945 &self,
946 session: &Session,
947 pr_number: u64,
948 pr_merged: bool,
949 ) -> bool {
950 if !pr_merged || matches!(session.status, SessionStatus::Done) {
951 return false;
952 }
953 self.engine.emit(Event::Notification(Notification {
954 id: format!("merged-{}", session.id),
955 kind: NotificationKind::WorkerDone,
956 title: format!("PR merged — {}", session.name),
957 body: format!("#{} merged successfully", pr_number),
958 session_id: Some(session.id.clone()),
959 created_at: now_millis(),
960 }));
961 if let Some(orch) = session.orchestrator_id.clone() {
964 let msg = crate::lifecycle::reactions::format_worker_done_reaction(session, pr_number);
965 if let Err(e) = self.engine.send_to_session(&orch, &msg).await {
966 tracing::warn!("send worker-done reaction to orchestrator {orch}: {e}");
967 }
968 }
969 if let Err(e) = self.engine.cleanup_session(&session.id).await {
970 tracing::warn!("cleanup_session {}: {e}", session.id);
971 }
972 {
974 let mut cache = self.enrichment_cache.lock().unwrap();
975 cache.remove(&session.id);
976 }
977 true
978 }
979
980 async fn sweep_retired_sessions(&self, retention: &SessionRetentionConfig) {
993 let Ok(sessions) = self.engine.store.list_sessions() else { return };
994 let Ok(orchestrators) = self.engine.store.list_orchestrators() else { return };
995 let orch_ids: std::collections::HashSet<&str> =
996 orchestrators.iter().map(|o| o.id.as_str()).collect();
997
998 let now = now_millis();
999 let retention_ms = retention.retention_millis();
1000
1001 for session in sessions {
1002 if !matches!(session.status, SessionStatus::Done | SessionStatus::Terminated) {
1003 continue;
1004 }
1005 if orch_ids.contains(session.id.as_str()) {
1006 continue;
1007 }
1008 let expired = match session.terminal_at {
1009 Some(t) => now.saturating_sub(t) >= retention_ms,
1010 None => true,
1011 };
1012 if !expired {
1013 continue;
1014 }
1015 if matches!(session.status, SessionStatus::Terminated) {
1024 self.engine.emit(Event::Notification(Notification {
1025 id: format!("retired-{}", session.id),
1026 kind: NotificationKind::WorkerRetired,
1027 title: format!("Worker retired — {}", session.name),
1028 body: "Session cleaned up without a detected PR merge".to_string(),
1029 session_id: Some(session.id.clone()),
1030 created_at: now,
1031 }));
1032 if let Some(orch) = session.orchestrator_id.clone() {
1033 let msg = crate::lifecycle::reactions::format_worker_retired_reaction(&session);
1034 if let Err(e) = self.engine.send_to_session(&orch, &msg).await {
1035 tracing::warn!("send worker-retired reaction to orchestrator {orch}: {e}");
1036 }
1037 }
1038 }
1039 if let Err(e) = self.engine.remove_session(&session.id).await {
1044 tracing::warn!("purge retired session {}: {e}", session.id);
1045 continue;
1046 }
1047 }
1048 }
1049}
1050
1051fn summarize_checks(pr_id: PrId, checks: &[CheckRun]) -> CIStatus {
1054 let total = checks.len() as u32;
1055 let failing = checks.iter().filter(|c| {
1056 c.conclusion.as_deref() == Some("failure")
1057 || c.conclusion.as_deref() == Some("timed_out")
1058 }).count() as u32;
1059 let passing = checks.iter().filter(|c| {
1060 c.conclusion.as_deref() == Some("success")
1061 }).count() as u32;
1062 let pending = total - failing - passing;
1063 CIStatus { pr_id, total, failing, passing, pending }
1064}
1065
1066fn derive_session_status(
1067 current: &SessionStatus,
1068 pr_status: &crate::github::PrStatus,
1069 ci: &CIStatus,
1070 has_changes_requested: bool,
1071) -> SessionStatus {
1072 if matches!(current, SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted) {
1074 return current.clone();
1075 }
1076 if pr_status.merged {
1077 return SessionStatus::Done;
1078 }
1079 if ci.failing > 0 {
1080 return SessionStatus::CiFailed;
1081 }
1082 if has_changes_requested {
1083 return SessionStatus::ReviewPending;
1084 }
1085 if pr_status.mergeable == Some(true) && ci.failing == 0 && ci.pending == 0 {
1086 return SessionStatus::Mergeable;
1087 }
1088 SessionStatus::PrOpen
1089}
1090
1091fn derive_gate_status(
1092 ci: &CIStatus,
1093 has_changes_requested: bool,
1094 mergeable: Option<bool>,
1095 previous: Option<&GateStatus>,
1096 now: i64,
1097) -> GateStatus {
1098 let ci_check = if ci.failing > 0 {
1099 GateCheck::Failing
1100 } else if ci.pending > 0 {
1101 GateCheck::Pending
1102 } else {
1103 GateCheck::Passing
1104 };
1105 let review_check = if has_changes_requested { GateCheck::Failing } else { GateCheck::Passing };
1106 let mergeable_check = match mergeable {
1107 Some(true) => GateCheck::Passing,
1108 Some(false) => GateCheck::Failing,
1109 None => GateCheck::Unknown,
1110 };
1111
1112 let unchanged = previous.is_some_and(|p| {
1113 p.ci == ci_check && p.review == review_check && p.mergeable == mergeable_check
1114 });
1115 let since = if unchanged { previous.unwrap().since } else { now };
1116
1117 GateStatus { ci: ci_check, review: review_check, mergeable: mergeable_check, since }
1118}
1119
1120fn compute_new_gate(
1135 current_status: &SessionStatus,
1136 ci: &CIStatus,
1137 has_changes_requested: bool,
1138 mergeable: Option<bool>,
1139 previous_gate: Option<&GateStatus>,
1140 now: i64,
1141) -> Option<GateStatus> {
1142 if matches!(current_status, SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted) {
1143 return previous_gate.cloned();
1144 }
1145 Some(derive_gate_status(ci, has_changes_requested, mergeable, previous_gate, now))
1146}
1147
1148#[cfg(test)]
1149mod tests {
1150 use super::*;
1151 use crate::types::SessionStatus;
1152
1153 #[test]
1154 fn summarize_checks_counts_failures() {
1155 let checks = vec![
1156 CheckRun { name: "lint".into(), status: "completed".into(), conclusion: Some("success".into()) },
1157 CheckRun { name: "test".into(), status: "completed".into(), conclusion: Some("failure".into()) },
1158 CheckRun { name: "build".into(), status: "in_progress".into(), conclusion: None },
1159 ];
1160 let ci = summarize_checks(1, &checks);
1161 assert_eq!(ci.total, 3);
1162 assert_eq!(ci.passing, 1);
1163 assert_eq!(ci.failing, 1);
1164 assert_eq!(ci.pending, 1);
1165 }
1166
1167 #[test]
1168 fn derive_status_merged_becomes_done() {
1169 let pr = crate::github::PrStatus {
1170 merged: true, state: "closed".into(), mergeable: None,
1171 title: "t".into(), number: 1, head_sha: String::new(),
1172 };
1173 let ci = CIStatus { pr_id: 1, total: 0, failing: 0, passing: 0, pending: 0 };
1174 let s = derive_session_status(&SessionStatus::PrOpen, &pr, &ci, false);
1175 assert!(matches!(s, SessionStatus::Done));
1176 }
1177
1178 #[test]
1179 fn derive_status_ci_failure_overrides_open() {
1180 let pr = crate::github::PrStatus {
1181 merged: false, state: "open".into(), mergeable: Some(true),
1182 title: "t".into(), number: 1, head_sha: String::new(),
1183 };
1184 let ci = CIStatus { pr_id: 1, total: 3, failing: 1, passing: 2, pending: 0 };
1185 let s = derive_session_status(&SessionStatus::PrOpen, &pr, &ci, false);
1186 assert!(matches!(s, SessionStatus::CiFailed));
1187 }
1188
1189 #[test]
1190 fn derive_status_all_green_becomes_mergeable() {
1191 let pr = crate::github::PrStatus {
1192 merged: false, state: "open".into(), mergeable: Some(true),
1193 title: "t".into(), number: 1, head_sha: String::new(),
1194 };
1195 let ci = CIStatus { pr_id: 1, total: 3, failing: 0, passing: 3, pending: 0 };
1196 let s = derive_session_status(&SessionStatus::PrOpen, &pr, &ci, false);
1197 assert!(matches!(s, SessionStatus::Mergeable));
1198 }
1199
1200 #[test]
1201 fn derive_gate_status_maps_raw_signals() {
1202 let ci = CIStatus { pr_id: 1, total: 3, passing: 1, failing: 1, pending: 1 };
1203 let gate = derive_gate_status(&ci, true, Some(false), None, 1_000);
1204 assert!(matches!(gate.ci, GateCheck::Failing));
1205 assert!(matches!(gate.review, GateCheck::Failing));
1206 assert!(matches!(gate.mergeable, GateCheck::Failing));
1207 assert_eq!(gate.since, 1_000, "first observation stamps `since` to `now`");
1208 }
1209
1210 #[test]
1211 fn derive_gate_status_maps_all_passing() {
1212 let ci = CIStatus { pr_id: 1, total: 2, passing: 2, failing: 0, pending: 0 };
1213 let gate = derive_gate_status(&ci, false, Some(true), None, 1_000);
1214 assert!(matches!(gate.ci, GateCheck::Passing));
1215 assert!(matches!(gate.review, GateCheck::Passing));
1216 assert!(matches!(gate.mergeable, GateCheck::Passing));
1217 }
1218
1219 #[test]
1220 fn derive_gate_status_maps_pending_ci_and_unknown_mergeable() {
1221 let ci = CIStatus { pr_id: 1, total: 2, passing: 0, failing: 0, pending: 2 };
1222 let gate = derive_gate_status(&ci, false, None, None, 1_000);
1223 assert!(matches!(gate.ci, GateCheck::Pending));
1224 assert!(matches!(gate.mergeable, GateCheck::Unknown));
1225 }
1226
1227 #[test]
1228 fn derive_gate_status_carries_since_forward_when_unchanged() {
1229 let ci = CIStatus { pr_id: 1, total: 1, passing: 1, failing: 0, pending: 0 };
1230 let previous = GateStatus {
1231 ci: GateCheck::Passing, review: GateCheck::Passing,
1232 mergeable: GateCheck::Passing, since: 500,
1233 };
1234 let gate = derive_gate_status(&ci, false, Some(true), Some(&previous), 9_999);
1235 assert_eq!(gate.since, 500, "unchanged combination keeps the original `since`");
1236 }
1237
1238 #[test]
1239 fn derive_gate_status_resets_since_when_combination_changes() {
1240 let ci = CIStatus { pr_id: 1, total: 1, passing: 0, failing: 1, pending: 0 };
1241 let previous = GateStatus {
1242 ci: GateCheck::Passing, review: GateCheck::Passing,
1243 mergeable: GateCheck::Passing, since: 500,
1244 };
1245 let gate = derive_gate_status(&ci, false, Some(true), Some(&previous), 9_999);
1246 assert!(matches!(gate.ci, GateCheck::Failing));
1247 assert_eq!(gate.since, 9_999, "a changed combination resets `since` to `now`");
1248 }
1249
1250 #[test]
1257 fn compute_new_gate_preserves_terminal_gate_even_when_live_signals_changed() {
1258 let previous = GateStatus {
1259 ci: GateCheck::Failing, review: GateCheck::Failing,
1260 mergeable: GateCheck::Failing, since: 111,
1261 };
1262 let ci = CIStatus { pr_id: 1, total: 3, passing: 3, failing: 0, pending: 0 };
1266
1267 for status in [SessionStatus::Terminated, SessionStatus::Interrupted, SessionStatus::Done] {
1268 let gate = compute_new_gate(&status, &ci, false, Some(true), Some(&previous), 9_999);
1269 assert_eq!(
1270 gate, Some(previous.clone()),
1271 "{status:?} session's gate must be carried forward unchanged, not recomputed",
1272 );
1273 }
1274 }
1275
1276 #[test]
1277 fn compute_new_gate_recomputes_for_non_terminal_status() {
1278 let previous = GateStatus {
1279 ci: GateCheck::Failing, review: GateCheck::Failing,
1280 mergeable: GateCheck::Failing, since: 111,
1281 };
1282 let ci = CIStatus { pr_id: 1, total: 3, passing: 3, failing: 0, pending: 0 };
1283
1284 let gate = compute_new_gate(&SessionStatus::PrOpen, &ci, false, Some(true), Some(&previous), 9_999);
1285 let gate = gate.expect("non-terminal status must produce a fresh gate");
1286 assert!(matches!(gate.ci, GateCheck::Passing), "must reflect the live all-green signals");
1287 assert_eq!(gate.since, 9_999, "changed combination resets `since` to `now`");
1288 }
1289
1290 #[test]
1291 fn derive_status_preserves_done() {
1292 let pr = crate::github::PrStatus {
1293 merged: false, state: "open".into(), mergeable: Some(true),
1294 title: "t".into(), number: 1, head_sha: String::new(),
1295 };
1296 let ci = CIStatus { pr_id: 1, total: 0, failing: 0, passing: 0, pending: 0 };
1297 let s = derive_session_status(&SessionStatus::Done, &pr, &ci, false);
1298 assert!(matches!(s, SessionStatus::Done));
1299 }
1300
1301 #[test]
1302 fn derive_status_preserves_terminated() {
1303 let pr = crate::github::PrStatus {
1304 merged: true, state: "closed".into(), mergeable: None, title: "t".into(), number: 1, head_sha: String::new(),
1306 };
1307 let ci = CIStatus { pr_id: 1, total: 0, failing: 0, passing: 0, pending: 0 };
1308 let s = derive_session_status(&SessionStatus::Terminated, &pr, &ci, false);
1309 assert!(matches!(s, SessionStatus::Terminated)); }
1311
1312 #[test]
1313 fn derive_status_changes_requested_becomes_review_pending() {
1314 let pr = crate::github::PrStatus {
1315 merged: false, state: "open".into(), mergeable: Some(true),
1316 title: "t".into(), number: 1, head_sha: String::new(),
1317 };
1318 let ci = CIStatus { pr_id: 1, total: 3, failing: 0, passing: 3, pending: 0 };
1319 let s = derive_session_status(&SessionStatus::PrOpen, &pr, &ci, true);
1320 assert!(matches!(s, SessionStatus::ReviewPending));
1321 }
1322
1323 fn test_session(id: &str, workspace: &str) -> crate::types::Session {
1324 crate::types::Session {
1325 id: id.into(), orchestrator_id: None, name: id.into(),
1326 repo: String::new(), status: SessionStatus::Working,
1327 agent_type: "claude-code".into(), cost_usd: 0.0, started_at: 0,
1328 pr_number: None, pr_id: None,
1329 workspace_path: Some(workspace.into()), pid: None,
1330 model: None, context_tokens: None, catalogue_path: None,
1331 context_used_pct: None, context_total_tokens: None, context_window_size: None,
1332 claude_session_id: None,
1333 summary: None,
1334 terminal_at: None, gate_status: None,
1335 }
1336 }
1337
1338 #[tokio::test]
1339 async fn poll_pids_leaves_interrupted_sessions_alone() {
1340 use crate::store::Store;
1341
1342 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1343 let mut s = test_session("interrupted-1", "/ws");
1344 s.status = SessionStatus::Interrupted;
1345 s.pid = Some(999_999); store.upsert_session(&s).unwrap();
1347 let engine = Engine::new(store.clone());
1348 let poller = Poller::new(engine);
1349
1350 poller.poll_pids().await;
1351
1352 let after = store.get_session("interrupted-1").unwrap().unwrap();
1353 assert!(
1354 matches!(after.status, SessionStatus::Interrupted),
1355 "poll_pids must not re-terminate an Interrupted session just because its stale pid is dead",
1356 );
1357 }
1358
1359 #[allow(clippy::await_holding_lock)]
1372 #[tokio::test]
1373 async fn poll_usage_ingests_transcript_into_store_and_emits_update() {
1374 use crate::{lifecycle::usage::{claude_project_slug, ENV_TEST_GUARD}, store::Store};
1375 use std::io::Write;
1376
1377 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1378 let projects_dir = tempfile::tempdir().unwrap();
1379 let workspace = "/tmp/poller-usage-probe-workspace";
1380 let project_dir = projects_dir.path().join(claude_project_slug(workspace));
1381 std::fs::create_dir_all(&project_dir).unwrap();
1382 let mut f = std::fs::File::create(project_dir.join("s.jsonl")).unwrap();
1383 writeln!(
1384 f,
1385 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}}}}}}"#
1386 ).unwrap();
1387 drop(f);
1388
1389 let prior = std::env::var("NINOX_CLAUDE_PROJECTS_DIR").ok();
1390 std::env::set_var("NINOX_CLAUDE_PROJECTS_DIR", projects_dir.path());
1391
1392 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1393 store.upsert_session(&test_session("s1", workspace)).unwrap();
1394 let engine = Engine::new(store.clone());
1395 let mut rx = engine.subscribe();
1396 let poller = Poller::new(engine);
1397
1398 poller.poll_usage().await;
1399
1400 match prior {
1401 Some(v) => std::env::set_var("NINOX_CLAUDE_PROJECTS_DIR", v),
1402 None => std::env::remove_var("NINOX_CLAUDE_PROJECTS_DIR"),
1403 }
1404
1405 let updated = store.get_session("s1").unwrap().unwrap();
1406 assert!(updated.cost_usd > 0.0, "cost_usd should be ingested, not 0.0000");
1407 assert_eq!(updated.context_tokens, Some(2 + 500 + 45000));
1408 assert_eq!(updated.model.as_deref(), Some("claude-fable-5"));
1409
1410 let evt = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
1411 .await
1412 .expect("SessionUpdated should be emitted")
1413 .unwrap();
1414 assert!(matches!(evt, Event::SessionUpdated(s, _fields) if s.id == "s1" && s.cost_usd > 0.0));
1415 }
1416
1417 #[tokio::test]
1423 async fn poll_context_updates_emits_only_for_changed_sessions() {
1424 use crate::store::Store;
1425
1426 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1427 let mut s1 = test_session("s1", "/ws1");
1428 let s2 = test_session("s2", "/ws2");
1429 store.upsert_session(&s1).unwrap();
1430 store.upsert_session(&s2).unwrap();
1431 let engine = Engine::new(store.clone());
1432 let mut rx = engine.subscribe();
1433 let poller = Poller::new(engine);
1434
1435 poller.poll_context_updates().await;
1439 let baseline_events = drain_events(&mut rx);
1440 assert!(baseline_events.is_empty(), "no prior cached state means no change to report");
1441
1442 s1.context_used_pct = Some(42.0);
1444 s1.cost_usd = 3.5;
1445 store.upsert_session(&s1).unwrap();
1446
1447 poller.poll_context_updates().await;
1448 let events = drain_events(&mut rx);
1449 assert_eq!(events.len(), 1, "only the changed session should emit");
1450 assert!(matches!(
1451 &events[0],
1452 Event::SessionUpdated(s, _fields) if s.id == "s1" && s.context_used_pct == Some(42.0) && s.cost_usd == 3.5
1453 ));
1454
1455 poller.poll_context_updates().await;
1457 assert!(drain_events(&mut rx).is_empty());
1458 }
1459
1460 fn drain_events(rx: &mut tokio::sync::broadcast::Receiver<Event>) -> Vec<Event> {
1462 let mut events = Vec::new();
1463 while let Ok(e) = rx.try_recv() {
1464 events.push(e);
1465 }
1466 events
1467 }
1468
1469 #[tokio::test]
1473 async fn metadata_sync_adopts_first_pr_and_flags_every_extra_once() {
1474 use crate::store::Store;
1475
1476 let sessions_dir = tempfile::tempdir().unwrap();
1477 let meta = serde_json::json!({
1478 "agentReportedPrNumber": "44",
1479 "agentReportedPrUrl": "https://github.com/org/repo/pull/44",
1480 "agentReportedPrs": [
1481 {"number": "42", "url": "https://github.com/org/repo/pull/42"},
1482 {"number": "43", "url": "https://github.com/org/repo/pull/43"},
1483 {"number": "44", "url": "https://github.com/org/repo/pull/44"},
1484 ],
1485 });
1486 std::fs::write(
1487 sessions_dir.path().join("s1.json"),
1488 serde_json::to_string(&meta).unwrap(),
1489 ).unwrap();
1490
1491 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1492 store.upsert_session(&test_session("s1", "/ws")).unwrap();
1493 let engine = Engine::new(store.clone());
1494 let mut rx = engine.subscribe();
1495 let poller = Poller::new(engine);
1496
1497 poller.sync_sessions_metadata(sessions_dir.path()).await;
1498
1499 let session = store.get_session("s1").unwrap().unwrap();
1500 assert_eq!(session.pr_number, Some(42), "first PR is the canonical one");
1501 assert!(matches!(session.status, SessionStatus::PrOpen));
1502 assert!(store.get_pr(43).unwrap().is_some(), "extra PR #43 recorded");
1503 assert!(store.get_pr(44).unwrap().is_some(), "extra PR #44 recorded");
1504 assert_eq!(
1505 store.get_pr(43).unwrap().unwrap().url,
1506 "https://github.com/org/repo/pull/43",
1507 );
1508
1509 let events = drain_events(&mut rx);
1510 let extra_notifs: Vec<_> = events.iter().filter(|e| matches!(
1511 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::ExtraPr
1512 )).collect();
1513 assert_eq!(extra_notifs.len(), 2, "one ExtraPr notification per extra PR");
1514
1515 let mut extra_pr_numbers: Vec<u64> = events.iter().filter_map(|e| match e {
1519 Event::ExtraPrDetected(pr) => Some(pr.number),
1520 _ => None,
1521 }).collect();
1522 extra_pr_numbers.sort();
1523 assert_eq!(
1524 extra_pr_numbers, vec![43, 44],
1525 "ExtraPrDetected must fire for every extra PR, not just the notification",
1526 );
1527 let pr_43 = events.iter().find_map(|e| match e {
1528 Event::ExtraPrDetected(pr) if pr.number == 43 => Some(pr),
1529 _ => None,
1530 }).expect("ExtraPrDetected for #43");
1531 assert_eq!(pr_43.session_id, "s1", "the ledger entry must point back at the owning session");
1532
1533 poller.sync_sessions_metadata(sessions_dir.path()).await;
1535 let events = drain_events(&mut rx);
1536 assert!(
1537 !events.iter().any(|e| matches!(e, Event::ExtraPrDetected(_))),
1538 "an extra PR already recorded in the store must not be re-detected on every tick",
1539 );
1540 assert!(
1541 !events.iter().any(|e| matches!(e, Event::Notification(_))),
1542 "extra PRs must not be re-notified on every tick",
1543 );
1544 }
1545
1546 #[tokio::test]
1549 async fn metadata_sync_single_pr_has_no_extra_notifications() {
1550 use crate::store::Store;
1551
1552 let sessions_dir = tempfile::tempdir().unwrap();
1553 let meta = serde_json::json!({
1554 "agentReportedPrNumber": "5",
1555 "agentReportedPrUrl": "https://github.com/org/repo/pull/5",
1556 });
1557 std::fs::write(
1558 sessions_dir.path().join("s1.json"),
1559 serde_json::to_string(&meta).unwrap(),
1560 ).unwrap();
1561
1562 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1563 store.upsert_session(&test_session("s1", "/ws")).unwrap();
1564 let engine = Engine::new(store.clone());
1565 let mut rx = engine.subscribe();
1566 let poller = Poller::new(engine);
1567
1568 poller.sync_sessions_metadata(sessions_dir.path()).await;
1569
1570 let session = store.get_session("s1").unwrap().unwrap();
1571 assert_eq!(session.pr_number, Some(5));
1572 assert!(matches!(session.status, SessionStatus::PrOpen));
1573 let events = drain_events(&mut rx);
1574 assert!(!events.iter().any(|e| matches!(e, Event::Notification(_))));
1575 }
1576
1577 #[tokio::test]
1580 async fn metadata_sync_delivers_work_requests_exactly_once() {
1581 use crate::store::Store;
1582
1583 let sessions_dir = tempfile::tempdir().unwrap();
1584 hooks::append_work_request(sessions_dir.path(), "s1", "Migrate the config loader").unwrap();
1585
1586 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1587 store.upsert_session(&test_session("s1", "/ws")).unwrap();
1588 let engine = Engine::new(store.clone());
1589 let mut rx = engine.subscribe();
1590 let poller = Poller::new(engine);
1591
1592 poller.sync_sessions_metadata(sessions_dir.path()).await;
1593
1594 let events = drain_events(&mut rx);
1595 let notif = events.iter().find_map(|e| match e {
1596 Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkRequested => Some(n),
1597 _ => None,
1598 }).expect("WorkRequested notification emitted");
1599 assert!(notif.body.contains("Migrate the config loader"));
1600 assert_eq!(notif.session_id.as_deref(), Some("s1"));
1601
1602 assert!(
1603 hooks::read_pending_work_requests(sessions_dir.path(), "s1").unwrap().is_empty(),
1604 "delivered requests must leave the pending set",
1605 );
1606
1607 poller.sync_sessions_metadata(sessions_dir.path()).await;
1608 let events = drain_events(&mut rx);
1609 assert!(
1610 !events.iter().any(|e| matches!(e, Event::Notification(_))),
1611 "delivered work requests must not fire again",
1612 );
1613 }
1614
1615 #[tokio::test]
1618 async fn metadata_sync_delivers_work_requests_from_terminated_sessions() {
1619 use crate::store::Store;
1620
1621 let sessions_dir = tempfile::tempdir().unwrap();
1622 hooks::append_work_request(sessions_dir.path(), "s1", "Follow-up refactor").unwrap();
1623
1624 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1625 let mut session = test_session("s1", "/ws");
1626 session.status = SessionStatus::Terminated;
1627 store.upsert_session(&session).unwrap();
1628 let engine = Engine::new(store.clone());
1629 let mut rx = engine.subscribe();
1630 let poller = Poller::new(engine);
1631
1632 poller.sync_sessions_metadata(sessions_dir.path()).await;
1633
1634 let events = drain_events(&mut rx);
1635 assert!(
1636 events.iter().any(|e| matches!(
1637 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkRequested
1638 )),
1639 "work requests outlive their session",
1640 );
1641 }
1642
1643 #[tokio::test]
1647 async fn extra_pr_ledger_row_backfills_after_notification_without_renotifying() {
1648 use crate::store::Store;
1649
1650 let sessions_dir = tempfile::tempdir().unwrap();
1651 let meta = serde_json::json!({
1652 "agentReportedPrs": [
1653 {"number": "7", "url": "https://github.com/org/repo/pull/7"},
1654 {"number": "9", "url": "https://github.com/org/repo/pull/9"},
1655 ],
1656 });
1657 std::fs::write(
1658 sessions_dir.path().join("s1.json"),
1659 serde_json::to_string(&meta).unwrap(),
1660 ).unwrap();
1661 hooks::mark_extra_prs_notified(sessions_dir.path(), "s1", &[9]).unwrap();
1663
1664 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1665 store.upsert_session(&test_session("s1", "/ws")).unwrap();
1666 let engine = Engine::new(store.clone());
1667 let mut rx = engine.subscribe();
1668 let poller = Poller::new(engine);
1669
1670 poller.sync_sessions_metadata(sessions_dir.path()).await;
1671
1672 assert!(
1673 store.get_pr(9).unwrap().is_some(),
1674 "already-notified extra PR must still get its ledger row backfilled",
1675 );
1676 let events = drain_events(&mut rx);
1677 assert!(
1678 !events.iter().any(|e| matches!(
1679 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::ExtraPr
1680 )),
1681 "backfilling the row must not re-notify",
1682 );
1683 }
1684
1685 #[tokio::test]
1689 async fn extra_pr_detection_survives_cross_repo_pr_number_collision() {
1690 use crate::store::Store;
1691
1692 let sessions_dir = tempfile::tempdir().unwrap();
1693 let meta = serde_json::json!({
1694 "agentReportedPrs": [
1695 {"number": "7", "url": "https://github.com/org/repo-a/pull/7"},
1696 {"number": "9", "url": "https://github.com/org/repo-a/pull/9"},
1697 ],
1698 });
1699 std::fs::write(
1700 sessions_dir.path().join("s1.json"),
1701 serde_json::to_string(&meta).unwrap(),
1702 ).unwrap();
1703
1704 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1705 store.upsert_session(&test_session("s1", "/ws")).unwrap();
1706 let other = PR {
1708 id: 9, number: 9, title: "other repo's PR".into(),
1709 url: "https://github.com/org/repo-b/pull/9".into(),
1710 body: String::new(), session_id: "other".into(),
1711 };
1712 store.upsert_pr(&other).unwrap();
1713
1714 let engine = Engine::new(store.clone());
1715 let mut rx = engine.subscribe();
1716 let poller = Poller::new(engine);
1717
1718 poller.sync_sessions_metadata(sessions_dir.path()).await;
1719
1720 let events = drain_events(&mut rx);
1721 assert!(
1722 events.iter().any(|e| matches!(
1723 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::ExtraPr
1724 )),
1725 "the collision must not suppress the extra-PR alert",
1726 );
1727 let row = store.get_pr(9).unwrap().unwrap();
1728 assert_eq!(row.session_id, "other", "the other repo's row must not be stolen");
1729 assert_eq!(row.url, "https://github.com/org/repo-b/pull/9");
1730 assert!(
1731 !events.iter().any(|e| matches!(e, Event::ExtraPrDetected(pr) if pr.number == 9)),
1732 "must not emit ExtraPrDetected for a row it didn't (and mustn't) write — that \
1733 would let s1 clobber the ledger UI's view of another session's PR",
1734 );
1735
1736 poller.sync_sessions_metadata(sessions_dir.path()).await;
1737 let events = drain_events(&mut rx);
1738 assert!(
1739 !events.iter().any(|e| matches!(e, Event::Notification(_))),
1740 "dedup must hold across ticks even without a prs row of our own",
1741 );
1742 }
1743
1744 #[tokio::test]
1745 async fn poll_usage_leaves_sessions_without_workspace_or_usage_untouched() {
1746 use crate::store::Store;
1747
1748 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1749 let mut no_ws = test_session("no-ws", "/does/not/matter");
1750 no_ws.workspace_path = None;
1751 store.upsert_session(&no_ws).unwrap();
1752 let engine = Engine::new(store.clone());
1753 let poller = Poller::new(engine);
1754
1755 poller.poll_usage().await;
1756
1757 let unchanged = store.get_session("no-ws").unwrap().unwrap();
1758 assert_eq!(unchanged.cost_usd, 0.0);
1759 assert_eq!(unchanged.context_tokens, None);
1760 }
1761
1762 #[derive(Default)]
1767 struct FakeGithub {
1768 pr_status_ok: std::sync::Mutex<HashMap<(String, String, u64), crate::github::PrStatus>>,
1772 branch_matches: std::sync::Mutex<HashMap<(String, String, String), crate::github::PrRef>>,
1773 calls: std::sync::Mutex<Vec<(String, String, u64)>>,
1775 mid_review_upsert: std::sync::Mutex<Option<(std::sync::Arc<crate::store::Store>, crate::types::Session)>>,
1781 mid_pr_status_upsert: std::sync::Mutex<Option<(std::sync::Arc<crate::store::Store>, crate::types::Session)>>,
1785 mid_review_delete: std::sync::Mutex<Option<(std::sync::Arc<crate::store::Store>, String)>>,
1789 mid_branch_lookup_upsert: std::sync::Mutex<Option<(std::sync::Arc<crate::store::Store>, crate::types::Session)>>,
1793 review_threads: std::sync::Mutex<Vec<crate::github::ReviewThread>>,
1796 issue_comments: std::sync::Mutex<Vec<crate::types::Comment>>,
1798 }
1799
1800 #[async_trait::async_trait]
1801 impl crate::github::GithubApi for FakeGithub {
1802 async fn get_pr_status(&self, owner: &str, repo: &str, pr_number: u64) -> anyhow::Result<crate::github::PrStatus> {
1803 self.calls.lock().unwrap().push((owner.to_string(), repo.to_string(), pr_number));
1804 if let Some((store, row)) = self.mid_pr_status_upsert.lock().unwrap().take() {
1805 store.upsert_session(&row).unwrap();
1806 }
1807 self.pr_status_ok.lock().unwrap()
1808 .get(&(owner.to_string(), repo.to_string(), pr_number))
1809 .cloned()
1810 .ok_or_else(|| anyhow::anyhow!("404 for {owner}/{repo}#{pr_number}"))
1811 }
1812 async fn get_ci_checks(&self, _owner: &str, _repo: &str, _head_sha: &str) -> anyhow::Result<Vec<CheckRun>> {
1813 Ok(vec![])
1814 }
1815 async fn get_review_threads(&self, _owner: &str, _repo: &str, _pr_number: u64) -> anyhow::Result<Vec<crate::github::ReviewThread>> {
1816 if let Some((store, row)) = self.mid_review_upsert.lock().unwrap().take() {
1817 store.upsert_session(&row).unwrap();
1818 }
1819 if let Some((store, id)) = self.mid_review_delete.lock().unwrap().take() {
1820 store.delete_session(&id).unwrap();
1821 }
1822 Ok(self.review_threads.lock().unwrap().clone())
1823 }
1824 async fn get_issue_comments(&self, _owner: &str, _repo: &str, _pr_number: u64) -> anyhow::Result<Vec<crate::types::Comment>> {
1825 Ok(self.issue_comments.lock().unwrap().clone())
1826 }
1827 async fn find_open_pr_for_branch(&self, owner: &str, repo: &str, branch: &str) -> anyhow::Result<Option<crate::github::PrRef>> {
1828 if let Some((store, row)) = self.mid_branch_lookup_upsert.lock().unwrap().take() {
1829 store.upsert_session(&row).unwrap();
1830 }
1831 Ok(self.branch_matches.lock().unwrap()
1832 .get(&(owner.to_string(), repo.to_string(), branch.to_string()))
1833 .cloned())
1834 }
1835 }
1836
1837 fn init_git_repo(branch: &str, remotes: &[(&str, &str)]) -> tempfile::TempDir {
1838 let dir = tempfile::tempdir().unwrap();
1839 let workspace = dir.path().to_string_lossy().to_string();
1840 let run = |args: &[&str]| {
1841 let status = std::process::Command::new("git")
1842 .args(["-C", &workspace]).args(args).status().unwrap();
1843 assert!(status.success(), "git {args:?} failed");
1844 };
1845 run(&["init", "-q"]);
1846 run(&["config", "user.email", "test@example.com"]);
1847 run(&["config", "user.name", "Test"]);
1848 run(&["commit", "--allow-empty", "-q", "-m", "init"]);
1849 run(&["checkout", "-q", "-b", branch]);
1850 for (name, url) in remotes {
1851 run(&["remote", "add", name, url]);
1852 }
1853 dir
1854 }
1855
1856 fn github_engine(store: std::sync::Arc<crate::store::Store>, gh: std::sync::Arc<FakeGithub>) -> std::sync::Arc<Engine> {
1857 Engine::new_with_github_api(store, gh as std::sync::Arc<dyn crate::github::GithubApi>)
1858 }
1859
1860 #[tokio::test]
1865 async fn poll_pr_reconciliation_adopts_pr_found_without_wrapper_metadata() {
1866 use crate::store::Store;
1867
1868 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
1869 let workspace = repo_dir.path().to_string_lossy().to_string();
1870
1871 let fake = std::sync::Arc::new(FakeGithub::default());
1872 fake.branch_matches.lock().unwrap().insert(
1873 ("Owner".to_string(), "repo".to_string(), "worker-branch".to_string()),
1874 crate::github::PrRef { number: 77, url: "https://github.com/Owner/repo/pull/77".into() },
1875 );
1876
1877 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1878 store.upsert_session(&test_session("s1", &workspace)).unwrap();
1879 let engine = github_engine(store.clone(), fake);
1880 let poller = Poller::new(engine);
1881
1882 poller.poll_pr_reconciliation().await;
1883
1884 let session = store.get_session("s1").unwrap().unwrap();
1885 assert_eq!(session.pr_number, Some(77), "no wrapper metadata was ever written — reconciliation must still find it");
1886 assert!(matches!(session.status, SessionStatus::PrOpen));
1887 assert_eq!(session.repo, "Owner/repo");
1888 }
1889
1890 #[tokio::test]
1896 async fn poll_pr_reconciliation_does_not_revert_mid_poll_statusline_fields() {
1897 use crate::store::Store;
1898
1899 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
1900 let workspace = repo_dir.path().to_string_lossy().to_string();
1901
1902 let fake = std::sync::Arc::new(FakeGithub::default());
1903 fake.branch_matches.lock().unwrap().insert(
1904 ("Owner".to_string(), "repo".to_string(), "worker-branch".to_string()),
1905 crate::github::PrRef { number: 77, url: "https://github.com/Owner/repo/pull/77".into() },
1906 );
1907
1908 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1909 let session = test_session("s1", &workspace);
1910 store.upsert_session(&session).unwrap();
1911
1912 let mut mid_poll = session.clone();
1913 mid_poll.cost_usd = 7.25;
1914 mid_poll.context_used_pct = Some(55.0);
1915 *fake.mid_branch_lookup_upsert.lock().unwrap() = Some((store.clone(), mid_poll));
1916
1917 let engine = github_engine(store.clone(), fake);
1918 let poller = Poller::new(engine);
1919
1920 poller.poll_pr_reconciliation().await;
1921
1922 let updated = store.get_session("s1").unwrap().unwrap();
1923 assert_eq!(updated.pr_number, Some(77), "the adoption write itself must still land");
1924 assert!(matches!(updated.status, SessionStatus::PrOpen));
1925 assert_eq!(
1926 updated.cost_usd, 7.25,
1927 "adoption must not revert cost_usd written mid-poll by the statusline process",
1928 );
1929 assert_eq!(updated.context_used_pct, Some(55.0), "mid-poll context fields must survive too");
1930 }
1931
1932 #[tokio::test]
1933 async fn poll_pr_reconciliation_leaves_sessions_with_no_matching_pr_untouched() {
1934 use crate::store::Store;
1935
1936 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
1937 let workspace = repo_dir.path().to_string_lossy().to_string();
1938
1939 let fake = std::sync::Arc::new(FakeGithub::default()); let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1942 store.upsert_session(&test_session("s1", &workspace)).unwrap();
1943 let engine = github_engine(store.clone(), fake);
1944 let poller = Poller::new(engine);
1945
1946 poller.poll_pr_reconciliation().await;
1947
1948 let session = store.get_session("s1").unwrap().unwrap();
1949 assert_eq!(session.pr_number, None);
1950 }
1951
1952 #[tokio::test]
1962 async fn poll_github_falls_back_to_other_remote_when_recorded_repo_404s() {
1963 use crate::store::Store;
1964
1965 let repo_dir = init_git_repo("worker-branch", &[
1966 ("origin", "https://github.com/OwnerA/repoA.git"),
1967 ("mirror", "https://github.com/OwnerB/repoB.git"),
1968 ]);
1969 let workspace = repo_dir.path().to_string_lossy().to_string();
1970
1971 let fake = std::sync::Arc::new(FakeGithub::default());
1972 fake.branch_matches.lock().unwrap().insert(
1973 ("OwnerB".to_string(), "repoB".to_string(), "worker-branch".to_string()),
1974 crate::github::PrRef { number: 99, url: "https://github.com/OwnerB/repoB/pull/99".into() },
1975 );
1976 fake.pr_status_ok.lock().unwrap().insert(
1977 ("OwnerB".to_string(), "repoB".to_string(), 99),
1978 crate::github::PrStatus {
1979 merged: false, state: "open".into(), mergeable: Some(true),
1980 title: "t".into(), number: 99, head_sha: "abc".into(),
1981 },
1982 );
1983 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1986 let mut session = test_session("s1", &workspace);
1987 session.repo = "OwnerA/repoA".into();
1988 session.pr_number = Some(50);
1989 store.upsert_session(&session).unwrap();
1990
1991 let engine = github_engine(store.clone(), fake.clone());
1992 let poller = Poller::new(engine);
1993
1994 poller.poll_github().await;
1995
1996 let updated = store.get_session("s1").unwrap().unwrap();
1997 assert_eq!(updated.repo, "OwnerB/repoB", "session.repo must self-heal to the remote that actually has the PR");
1998 assert_eq!(updated.pr_number, Some(99), "must adopt the mirror's own PR number, not reuse the tracked repo's number");
1999
2000 let calls = fake.calls.lock().unwrap().clone();
2001 assert_eq!(
2002 calls,
2003 vec![("OwnerA".to_string(), "repoA".to_string(), 50), ("OwnerB".to_string(), "repoB".to_string(), 99)],
2004 "the repo on record must be tried first (by its own number), the mirror only as a branch-matched fallback",
2005 );
2006 }
2007
2008 #[tokio::test]
2017 async fn poll_github_does_not_adopt_an_unrelated_pr_that_shares_the_tracked_number() {
2018 use crate::store::Store;
2019
2020 let repo_dir = init_git_repo("worker-branch", &[
2021 ("origin", "https://github.com/OwnerA/repoA.git"),
2022 ("mirror", "https://github.com/OwnerB/repoB.git"),
2023 ]);
2024 let workspace = repo_dir.path().to_string_lossy().to_string();
2025
2026 let fake = std::sync::Arc::new(FakeGithub::default());
2027 fake.pr_status_ok.lock().unwrap().insert(
2030 ("OwnerB".to_string(), "repoB".to_string(), 50),
2031 crate::github::PrStatus {
2032 merged: false, state: "open".into(), mergeable: Some(true),
2033 title: "someone else's unrelated PR".into(), number: 50, head_sha: "zzz".into(),
2034 },
2035 );
2036 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2039 let mut session = test_session("s1", &workspace);
2040 session.repo = "OwnerA/repoA".into();
2041 session.pr_number = Some(50);
2042 store.upsert_session(&session).unwrap();
2043
2044 let engine = github_engine(store.clone(), fake.clone());
2045 let poller = Poller::new(engine);
2046
2047 poller.poll_github().await;
2048
2049 let updated = store.get_session("s1").unwrap().unwrap();
2050 assert_eq!(updated.repo, "OwnerA/repoA", "must not adopt the mirror just because it happens to have a same-numbered PR");
2051 assert_eq!(updated.pr_number, Some(50));
2052
2053 let calls = fake.calls.lock().unwrap().clone();
2054 assert!(
2055 !calls.contains(&("OwnerB".to_string(), "repoB".to_string(), 50)),
2056 "must never retry the tracked number against another repo's get_pr_status: {calls:?}",
2057 );
2058 }
2059
2060 #[tokio::test]
2072 async fn poll_github_persists_pr_id_so_later_polls_dont_regress_it() {
2073 use crate::store::Store;
2074
2075 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
2076 let workspace = repo_dir.path().to_string_lossy().to_string();
2077
2078 let fake = std::sync::Arc::new(FakeGithub::default());
2079 fake.pr_status_ok.lock().unwrap().insert(
2080 ("Owner".to_string(), "repo".to_string(), 50),
2081 crate::github::PrStatus {
2082 merged: false, state: "open".into(), mergeable: Some(true),
2083 title: "t".into(), number: 50, head_sha: "abc".into(),
2084 },
2085 );
2086
2087 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2088 let mut session = test_session("s1", &workspace);
2089 session.repo = "Owner/repo".into();
2090 session.pr_number = Some(50);
2091 store.upsert_session(&session).unwrap();
2092
2093 let engine = github_engine(store.clone(), fake);
2094 let poller = Poller::new(engine);
2095
2096 poller.poll_github().await;
2097
2098 let updated = store.get_session("s1").unwrap().unwrap();
2099 assert_eq!(
2100 updated.pr_id, Some(50),
2101 "pr_id discovered by poll_github must be persisted to the store, or a later \
2102 read-modify-write by any other poller will re-broadcast pr_id: None and flicker the UI",
2103 );
2104 }
2105
2106 #[tokio::test]
2114 async fn poll_github_does_not_recompute_gate_for_terminated_session_with_unresolved_pr() {
2115 use crate::store::Store;
2116
2117 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
2118 let workspace = repo_dir.path().to_string_lossy().to_string();
2119
2120 let fake = std::sync::Arc::new(FakeGithub::default());
2121 fake.pr_status_ok.lock().unwrap().insert(
2122 ("Owner".to_string(), "repo".to_string(), 50),
2123 crate::github::PrStatus {
2124 merged: false, state: "open".into(), mergeable: Some(true),
2130 title: "t".into(), number: 50, head_sha: "abc".into(),
2131 },
2132 );
2133
2134 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2135 let mut session = test_session("s1", &workspace);
2136 session.repo = "Owner/repo".into();
2137 session.pr_number = Some(50);
2138 session.status = SessionStatus::Terminated;
2139 let stale_gate = GateStatus {
2140 ci: GateCheck::Failing, review: GateCheck::Failing,
2141 mergeable: GateCheck::Failing, since: 111,
2142 };
2143 session.gate_status = Some(stale_gate.clone());
2144 store.upsert_session(&session).unwrap();
2145
2146 let engine = github_engine(store.clone(), fake);
2147 let poller = Poller::new(engine);
2148
2149 poller.poll_github().await;
2150
2151 let updated = store.get_session("s1").unwrap().unwrap();
2152 assert!(
2153 matches!(updated.status, SessionStatus::Terminated),
2154 "status must stay Terminated (derive_session_status's own terminal guard)",
2155 );
2156 assert_eq!(
2157 updated.gate_status, Some(stale_gate),
2158 "gate_status must be carried forward unchanged for a terminal session, \
2159 even though live CI/mergeable data disagrees with it",
2160 );
2161 }
2162
2163 #[tokio::test]
2176 async fn poll_github_persists_pr_id_when_the_self_healed_pr_is_already_merged() {
2177 use crate::store::Store;
2178
2179 let repo_dir = init_git_repo("worker-branch", &[
2180 ("origin", "https://github.com/OwnerA/repoA.git"),
2181 ("mirror", "https://github.com/OwnerB/repoB.git"),
2182 ]);
2183 let workspace = repo_dir.path().to_string_lossy().to_string();
2184
2185 let fake = std::sync::Arc::new(FakeGithub::default());
2186 fake.branch_matches.lock().unwrap().insert(
2187 ("OwnerB".to_string(), "repoB".to_string(), "worker-branch".to_string()),
2188 crate::github::PrRef { number: 99, url: "https://github.com/OwnerB/repoB/pull/99".into() },
2189 );
2190 fake.pr_status_ok.lock().unwrap().insert(
2191 ("OwnerB".to_string(), "repoB".to_string(), 99),
2192 crate::github::PrStatus {
2193 merged: true, state: "closed".into(), mergeable: None,
2194 title: "t".into(), number: 99, head_sha: "abc".into(),
2195 },
2196 );
2197 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2201 let mut session = test_session("s1", &workspace);
2202 session.repo = "OwnerA/repoA".into();
2203 session.pr_number = Some(50);
2204 store.upsert_session(&session).unwrap();
2205
2206 let engine = github_engine(store.clone(), fake);
2207 let poller = Poller::new(engine);
2208
2209 poller.poll_github().await;
2210
2211 let updated = store.get_session("s1").unwrap().unwrap();
2212 assert_eq!(updated.repo, "OwnerB/repoB", "self-heal must still land");
2213 assert_eq!(updated.pr_number, Some(99), "self-heal must still land");
2214 assert_eq!(
2215 updated.pr_id, Some(99),
2216 "pr_id must be persisted even though the self-healed PR is already merged and \
2217 merge detection short-circuits the rest of this loop iteration",
2218 );
2219 assert!(matches!(updated.status, SessionStatus::Done), "merged PR still transitions to Done");
2220 }
2221
2222 #[tokio::test]
2232 async fn poll_github_status_write_does_not_revert_mid_poll_statusline_fields() {
2233 use crate::store::Store;
2234
2235 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
2236 let workspace = repo_dir.path().to_string_lossy().to_string();
2237
2238 let fake = std::sync::Arc::new(FakeGithub::default());
2239 fake.pr_status_ok.lock().unwrap().insert(
2240 ("Owner".to_string(), "repo".to_string(), 50),
2241 crate::github::PrStatus {
2242 merged: false, state: "open".into(), mergeable: Some(true),
2243 title: "t".into(), number: 50, head_sha: "abc".into(),
2244 },
2245 );
2246
2247 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2248 let mut session = test_session("s1", &workspace);
2249 session.repo = "Owner/repo".into();
2250 session.pr_number = Some(50);
2251 session.pr_id = Some(50); store.upsert_session(&session).unwrap();
2253
2254 let mut mid_poll = session.clone();
2256 mid_poll.cost_usd = 7.25;
2257 mid_poll.context_used_pct = Some(55.0);
2258 mid_poll.context_tokens = Some(123);
2259 *fake.mid_review_upsert.lock().unwrap() = Some((store.clone(), mid_poll));
2260
2261 let engine = github_engine(store.clone(), fake);
2262 let poller = Poller::new(engine);
2263
2264 poller.poll_github().await;
2265
2266 let updated = store.get_session("s1").unwrap().unwrap();
2267 assert!(
2268 matches!(updated.status, SessionStatus::Mergeable),
2269 "the status write itself must still land (open PR, all-green signals): {:?}",
2270 updated.status,
2271 );
2272 assert!(updated.gate_status.is_some(), "the gate write must still land");
2273 assert_eq!(
2274 updated.cost_usd, 7.25,
2275 "persisting status/gate must not revert cost_usd written mid-poll by the statusline process",
2276 );
2277 assert_eq!(updated.context_used_pct, Some(55.0), "mid-poll context fields must survive too");
2278 assert_eq!(updated.context_tokens, Some(123));
2279 }
2280
2281 #[tokio::test]
2287 async fn poll_github_self_heal_write_does_not_revert_mid_poll_statusline_fields() {
2288 use crate::store::Store;
2289
2290 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
2291 let workspace = repo_dir.path().to_string_lossy().to_string();
2292
2293 let fake = std::sync::Arc::new(FakeGithub::default());
2294 fake.pr_status_ok.lock().unwrap().insert(
2295 ("Owner".to_string(), "repo".to_string(), 50),
2296 crate::github::PrStatus {
2297 merged: false, state: "open".into(), mergeable: Some(true),
2298 title: "t".into(), number: 50, head_sha: "abc".into(),
2299 },
2300 );
2301
2302 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2303 let mut session = test_session("s1", &workspace);
2304 session.repo = "Owner/repo".into();
2305 session.pr_number = Some(50);
2306 session.pr_id = None; store.upsert_session(&session).unwrap();
2308
2309 let mut mid_poll = session.clone();
2310 mid_poll.cost_usd = 7.25;
2311 mid_poll.context_used_pct = Some(55.0);
2312 *fake.mid_pr_status_upsert.lock().unwrap() = Some((store.clone(), mid_poll));
2313
2314 let engine = github_engine(store.clone(), fake);
2315 let poller = Poller::new(engine);
2316
2317 poller.poll_github().await;
2318
2319 let updated = store.get_session("s1").unwrap().unwrap();
2320 assert_eq!(updated.pr_id, Some(50), "the self-heal write itself must still land");
2321 assert_eq!(
2322 updated.cost_usd, 7.25,
2323 "the self-heal write must not revert cost_usd written mid-poll by the statusline process",
2324 );
2325 assert_eq!(updated.context_used_pct, Some(55.0), "mid-poll context fields must survive too");
2326 }
2327
2328 #[tokio::test]
2332 async fn poll_github_status_write_does_not_resurrect_a_row_deleted_mid_poll() {
2333 use crate::store::Store;
2334
2335 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
2336 let workspace = repo_dir.path().to_string_lossy().to_string();
2337
2338 let fake = std::sync::Arc::new(FakeGithub::default());
2339 fake.pr_status_ok.lock().unwrap().insert(
2340 ("Owner".to_string(), "repo".to_string(), 50),
2341 crate::github::PrStatus {
2342 merged: false, state: "open".into(), mergeable: Some(true),
2343 title: "t".into(), number: 50, head_sha: "abc".into(),
2344 },
2345 );
2346
2347 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2348 let mut session = test_session("s1", &workspace);
2349 session.repo = "Owner/repo".into();
2350 session.pr_number = Some(50);
2351 session.pr_id = Some(50); store.upsert_session(&session).unwrap();
2353
2354 *fake.mid_review_delete.lock().unwrap() = Some((store.clone(), "s1".to_string()));
2355
2356 let engine = github_engine(store.clone(), fake);
2357 let poller = Poller::new(engine);
2358
2359 poller.poll_github().await;
2360
2361 assert!(
2362 store.get_session("s1").unwrap().is_none(),
2363 "a session deleted mid-poll must not be re-inserted by the status/gate write",
2364 );
2365 }
2366
2367 #[tokio::test]
2374 async fn poll_github_status_gate_emit_flags_exactly_status_and_gate() {
2375 use crate::store::Store;
2376
2377 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
2378 let workspace = repo_dir.path().to_string_lossy().to_string();
2379
2380 let fake = std::sync::Arc::new(FakeGithub::default());
2381 fake.pr_status_ok.lock().unwrap().insert(
2382 ("Owner".to_string(), "repo".to_string(), 50),
2383 crate::github::PrStatus {
2384 merged: false, state: "open".into(), mergeable: Some(true),
2385 title: "t".into(), number: 50, head_sha: "abc".into(),
2386 },
2387 );
2388
2389 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2390 let mut session = test_session("s1", &workspace);
2391 session.repo = "Owner/repo".into();
2392 session.pr_number = Some(50);
2393 session.pr_id = Some(50); store.upsert_session(&session).unwrap();
2395
2396 let engine = github_engine(store.clone(), fake);
2397 let mut rx = engine.subscribe();
2398 let poller = Poller::new(engine);
2399
2400 poller.poll_github().await;
2401
2402 let events = drain_events(&mut rx);
2403 let fields: Vec<SessionFields> = events.iter().filter_map(|e| match e {
2404 Event::SessionUpdated(s, f) if s.id == "s1" => Some(*f),
2405 _ => None,
2406 }).collect();
2407 assert_eq!(
2408 fields, vec![SessionFields::STATUS | SessionFields::GATE],
2409 "with nothing to self-heal, the tick's only SessionUpdated is the status/gate \
2410 write, flagged for exactly the two fields it persisted — no PR_LINK",
2411 );
2412 }
2413
2414 #[tokio::test]
2420 async fn poll_github_self_heal_emits_pr_link_flagged_update() {
2421 use crate::store::Store;
2422
2423 let repo_dir = init_git_repo("worker-branch", &[
2424 ("origin", "https://github.com/OwnerA/repoA.git"),
2425 ("mirror", "https://github.com/OwnerB/repoB.git"),
2426 ]);
2427 let workspace = repo_dir.path().to_string_lossy().to_string();
2428
2429 let fake = std::sync::Arc::new(FakeGithub::default());
2430 fake.branch_matches.lock().unwrap().insert(
2431 ("OwnerB".to_string(), "repoB".to_string(), "worker-branch".to_string()),
2432 crate::github::PrRef { number: 99, url: "https://github.com/OwnerB/repoB/pull/99".into() },
2433 );
2434 fake.pr_status_ok.lock().unwrap().insert(
2435 ("OwnerB".to_string(), "repoB".to_string(), 99),
2436 crate::github::PrStatus {
2437 merged: false, state: "open".into(), mergeable: Some(true),
2438 title: "t".into(), number: 99, head_sha: "abc".into(),
2439 },
2440 );
2441 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2445 let mut session = test_session("s1", &workspace);
2446 session.repo = "OwnerA/repoA".into();
2447 session.pr_number = Some(50);
2448 store.upsert_session(&session).unwrap();
2449
2450 let engine = github_engine(store.clone(), fake);
2451 let mut rx = engine.subscribe();
2452 let poller = Poller::new(engine);
2453
2454 poller.poll_github().await;
2455
2456 let events = drain_events(&mut rx);
2457 let healed = events.iter().find_map(|e| match e {
2458 Event::SessionUpdated(s, f) if *f == SessionFields::PR_LINK => Some(s.clone()),
2459 _ => None,
2460 }).expect(
2461 "self-heal must emit its own PR_LINK-flagged SessionUpdated — it's the only \
2462 channel by which the corrected pr fields reach the GUI",
2463 );
2464 assert_eq!(healed.repo, "OwnerB/repoB");
2465 assert_eq!(healed.pr_number, Some(99));
2466 assert_eq!(healed.pr_id, Some(99));
2467
2468 for e in &events {
2469 if let Event::SessionUpdated(_, f) = e {
2470 if f.contains(SessionFields::STATUS) {
2471 assert!(
2472 !f.contains(SessionFields::PR_LINK),
2473 "the status/gate emit must not over-flag PR_LINK on the self-heal's behalf",
2474 );
2475 }
2476 }
2477 }
2478 }
2479
2480 #[tokio::test]
2484 async fn poll_github_notifies_once_when_every_remote_fails_and_rearms_after_recovery() {
2485 use crate::store::Store;
2486
2487 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/OwnerA/repoA.git")]);
2488 let workspace = repo_dir.path().to_string_lossy().to_string();
2489
2490 let fake = std::sync::Arc::new(FakeGithub::default()); let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2493 let mut session = test_session("s1", &workspace);
2494 session.repo = "OwnerA/repoA".into();
2495 session.pr_number = Some(50);
2496 store.upsert_session(&session).unwrap();
2497
2498 let engine = github_engine(store.clone(), fake.clone());
2499 let mut rx = engine.subscribe();
2500 let poller = Poller::new(engine);
2501
2502 poller.poll_github().await;
2503 let events = drain_events(&mut rx);
2504 let failures = |evs: &[Event]| evs.iter().filter(|e| matches!(
2505 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::GithubLookupFailed
2506 )).count();
2507 assert_eq!(failures(&events), 1, "first failure must notify");
2508
2509 poller.poll_github().await;
2510 let events = drain_events(&mut rx);
2511 assert_eq!(failures(&events), 0, "repeated failure must not re-notify");
2512
2513 fake.pr_status_ok.lock().unwrap().insert(
2515 ("OwnerA".to_string(), "repoA".to_string(), 50),
2516 crate::github::PrStatus {
2517 merged: false, state: "open".into(), mergeable: Some(true),
2518 title: "t".into(), number: 50, head_sha: "abc".into(),
2519 },
2520 );
2521 poller.poll_github().await;
2522
2523 fake.pr_status_ok.lock().unwrap().clear();
2525 poller.poll_github().await;
2526 let events = drain_events(&mut rx);
2527 assert_eq!(failures(&events), 1, "must notify again after recovering and failing anew");
2528 }
2529
2530 fn open_pr_session(id: &str, workspace: &str, pr_number: u64) -> Session {
2540 let mut s = test_session(id, workspace);
2541 s.repo = "Owner/repo".into();
2542 s.pr_number = Some(pr_number);
2543 s
2544 }
2545
2546 fn open_pr_fake(pr_number: u64) -> std::sync::Arc<FakeGithub> {
2547 let fake = std::sync::Arc::new(FakeGithub::default());
2548 fake.pr_status_ok.lock().unwrap().insert(
2549 ("Owner".to_string(), "repo".to_string(), pr_number),
2550 crate::github::PrStatus {
2551 merged: false, state: "open".into(), mergeable: Some(true),
2552 title: "t".into(), number: pr_number, head_sha: "abc".into(),
2553 },
2554 );
2555 fake
2556 }
2557
2558 fn comment_events(evs: &[Event]) -> Vec<Comment> {
2559 evs.iter().filter_map(|e| match e {
2560 Event::ReviewComment { comment, .. } => Some(comment.clone()),
2561 _ => None,
2562 }).collect()
2563 }
2564
2565 #[tokio::test]
2569 async fn poll_github_captures_commented_review() {
2570 use crate::store::Store;
2571
2572 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
2573 let workspace = repo_dir.path().to_string_lossy().to_string();
2574
2575 let fake = open_pr_fake(50);
2576 fake.review_threads.lock().unwrap().push(crate::github::ReviewThread {
2577 id: 501, author: "carol".into(), body: "Looks fine overall".into(),
2578 path: None, line: None, state: "COMMENTED".into(), created_at: 1_000,
2579 });
2580
2581 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2582 store.upsert_session(&open_pr_session("s1", &workspace, 50)).unwrap();
2583
2584 let engine = github_engine(store.clone(), fake);
2585 let mut rx = engine.subscribe();
2586 let poller = Poller::new(engine);
2587 poller.poll_github().await;
2588
2589 let persisted = store.list_comments().unwrap();
2590 assert_eq!(persisted.len(), 1);
2591 assert_eq!(persisted[0].id, 501);
2592 assert_eq!(persisted[0].body, "Looks fine overall");
2593 assert_eq!(persisted[0].created_at, 1_000, "created_at must come from the GitHub payload, not be hard-coded 0");
2594
2595 let emitted = comment_events(&drain_events(&mut rx));
2596 assert_eq!(emitted.len(), 1, "must emit Event::ReviewComment for display even though it's not CHANGES_REQUESTED");
2597 assert_eq!(emitted[0].id, 501);
2598 }
2599
2600 #[tokio::test]
2606 async fn poll_github_captures_changes_requested_review_with_empty_body() {
2607 use crate::store::Store;
2608
2609 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
2610 let workspace = repo_dir.path().to_string_lossy().to_string();
2611
2612 let fake = open_pr_fake(50);
2613 fake.review_threads.lock().unwrap().push(crate::github::ReviewThread {
2614 id: 503, author: "frank".into(), body: String::new(),
2615 path: None, line: None, state: "CHANGES_REQUESTED".into(), created_at: 4_000,
2616 });
2617
2618 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2619 store.upsert_session(&open_pr_session("s1", &workspace, 50)).unwrap();
2620
2621 let engine = github_engine(store.clone(), fake);
2622 let mut rx = engine.subscribe();
2623 let poller = Poller::new(engine);
2624 poller.poll_github().await;
2625
2626 let persisted = store.list_comments().unwrap();
2627 assert_eq!(persisted.len(), 1, "an empty-body CHANGES_REQUESTED review must still be captured");
2628 assert_eq!(persisted[0].id, 503);
2629
2630 let events = drain_events(&mut rx);
2631 assert_eq!(comment_events(&events).len(), 1);
2632 assert!(
2633 events.iter().any(|e| matches!(
2634 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::PrNeedsAttention
2635 )),
2636 "an empty-body CHANGES_REQUESTED review must still trigger the reaction/notification path",
2637 );
2638 }
2639
2640 #[tokio::test]
2643 async fn poll_github_captures_inline_review_comment() {
2644 use crate::store::Store;
2645
2646 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
2647 let workspace = repo_dir.path().to_string_lossy().to_string();
2648
2649 let fake = open_pr_fake(50);
2650 fake.review_threads.lock().unwrap().push(crate::github::ReviewThread {
2651 id: 502, author: "dave".into(), body: "nit: rename this".into(),
2652 path: Some("src/main.rs".into()), line: Some(42),
2653 state: "COMMENTED".into(), created_at: 2_000,
2654 });
2655
2656 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2657 store.upsert_session(&open_pr_session("s1", &workspace, 50)).unwrap();
2658
2659 let engine = github_engine(store.clone(), fake);
2660 let poller = Poller::new(engine);
2661 poller.poll_github().await;
2662
2663 let persisted = store.list_comments().unwrap();
2664 assert_eq!(persisted.len(), 1);
2665 assert_eq!(persisted[0].path.as_deref(), Some("src/main.rs"));
2666 assert_eq!(persisted[0].line, Some(42));
2667 }
2668
2669 #[tokio::test]
2673 async fn poll_github_captures_issue_comment() {
2674 use crate::store::Store;
2675
2676 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
2677 let workspace = repo_dir.path().to_string_lossy().to_string();
2678
2679 let fake = open_pr_fake(50);
2680 fake.issue_comments.lock().unwrap().push(Comment {
2681 id: 900, pr_id: 0, author: "erin".into(), body: "Great work, merging soon".into(),
2682 path: None, line: None, created_at: 3_000,
2683 });
2684
2685 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2686 store.upsert_session(&open_pr_session("s1", &workspace, 50)).unwrap();
2687
2688 let engine = github_engine(store.clone(), fake);
2689 let mut rx = engine.subscribe();
2690 let poller = Poller::new(engine);
2691 poller.poll_github().await;
2692
2693 let persisted = store.list_comments().unwrap();
2694 assert_eq!(persisted.len(), 1);
2695 assert_eq!(persisted[0].id, 900);
2696 assert_eq!(persisted[0].pr_id, 50, "pr_id must be stamped from the resolved PR, not left at the fake's placeholder value");
2697
2698 let emitted = comment_events(&drain_events(&mut rx));
2699 assert_eq!(emitted.len(), 1);
2700 assert_eq!(emitted[0].author, "erin");
2701 }
2702
2703 #[tokio::test]
2707 async fn poll_github_does_not_duplicate_comments_across_repeated_polls() {
2708 use crate::store::Store;
2709
2710 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
2711 let workspace = repo_dir.path().to_string_lossy().to_string();
2712
2713 let fake = open_pr_fake(50);
2714 fake.review_threads.lock().unwrap().push(crate::github::ReviewThread {
2715 id: 501, author: "carol".into(), body: "Looks fine overall".into(),
2716 path: None, line: None, state: "COMMENTED".into(), created_at: 1_000,
2717 });
2718
2719 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2720 store.upsert_session(&open_pr_session("s1", &workspace, 50)).unwrap();
2721
2722 let engine = github_engine(store.clone(), fake);
2723 let mut rx = engine.subscribe();
2724 let poller = Poller::new(engine);
2725
2726 poller.poll_github().await;
2727 assert_eq!(comment_events(&drain_events(&mut rx)).len(), 1);
2728
2729 poller.poll_github().await;
2730 assert_eq!(
2731 comment_events(&drain_events(&mut rx)).len(), 0,
2732 "the second tick's in-memory seen_comment_ids must skip an already-captured comment",
2733 );
2734 assert_eq!(store.list_comments().unwrap().len(), 1);
2735 }
2736
2737 #[tokio::test]
2744 async fn poll_github_restart_rehydrates_without_duplicating_comments() {
2745 use crate::store::Store;
2746
2747 let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
2748 let workspace = repo_dir.path().to_string_lossy().to_string();
2749
2750 let fake = open_pr_fake(50);
2751 fake.review_threads.lock().unwrap().push(crate::github::ReviewThread {
2752 id: 501, author: "carol".into(), body: "Looks fine overall".into(),
2753 path: None, line: None, state: "COMMENTED".into(), created_at: 1_000,
2754 });
2755
2756 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2757 store.upsert_session(&open_pr_session("s1", &workspace, 50)).unwrap();
2758
2759 {
2760 let engine = github_engine(store.clone(), fake.clone());
2761 let poller = Poller::new(engine);
2762 poller.poll_github().await;
2763 }
2764 assert_eq!(store.list_comments().unwrap().len(), 1);
2765
2766 let engine = github_engine(store.clone(), fake);
2769 let poller = Poller::new(engine);
2770 poller.poll_github().await;
2771
2772 let persisted = store.list_comments().unwrap();
2773 assert_eq!(persisted.len(), 1, "restart must not duplicate an already-persisted comment");
2774 assert_eq!(persisted[0].id, 501);
2775 }
2776
2777 struct FakeUpdateSource(std::sync::Mutex<Option<semver::Version>>);
2783
2784 #[async_trait::async_trait]
2785 impl UpdateSource for FakeUpdateSource {
2786 async fn latest_version(&self, _package: &str) -> anyhow::Result<Option<semver::Version>> {
2787 Ok(self.0.lock().unwrap().clone())
2788 }
2789 }
2790
2791 fn bare_engine() -> Arc<Engine> {
2792 use crate::store::Store;
2793 let store = Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2794 Engine::new(store)
2795 }
2796
2797 fn update_events(evs: &[Event]) -> usize {
2798 evs.iter().filter(|e| matches!(
2799 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::UpdateAvailable
2800 )).count()
2801 }
2802
2803 #[tokio::test]
2804 async fn poll_update_check_notifies_once_then_again_for_a_newer_version() {
2805 let engine = bare_engine();
2806 let mut rx = engine.subscribe();
2807 let source = Arc::new(FakeUpdateSource(std::sync::Mutex::new(Some(semver::Version::new(99, 0, 0)))));
2808 let poller = Poller::new(engine).with_update_source(source.clone());
2809
2810 poller.poll_update_check().await;
2811 assert_eq!(update_events(&drain_events(&mut rx)), 1, "first sighting of a newer version must notify");
2812
2813 poller.poll_update_check().await;
2814 assert_eq!(update_events(&drain_events(&mut rx)), 0, "same version again must not re-notify");
2815
2816 *source.0.lock().unwrap() = Some(semver::Version::new(100, 0, 0));
2817 poller.poll_update_check().await;
2818 assert_eq!(update_events(&drain_events(&mut rx)), 1, "an even newer version must notify again");
2819 }
2820
2821 #[tokio::test]
2822 async fn poll_update_check_no_notification_when_already_current() {
2823 let engine = bare_engine();
2824 let mut rx = engine.subscribe();
2825 let current: semver::Version = env!("CARGO_PKG_VERSION").parse().unwrap();
2826 let source = Arc::new(FakeUpdateSource(std::sync::Mutex::new(Some(current))));
2827 let poller = Poller::new(engine).with_update_source(source);
2828
2829 poller.poll_update_check().await;
2830 assert_eq!(update_events(&drain_events(&mut rx)), 0);
2831 }
2832
2833 #[tokio::test]
2834 async fn poll_update_check_no_notification_when_source_has_nothing() {
2835 let engine = bare_engine();
2836 let mut rx = engine.subscribe();
2837 let source = Arc::new(FakeUpdateSource(std::sync::Mutex::new(None)));
2838 let poller = Poller::new(engine).with_update_source(source);
2839
2840 poller.poll_update_check().await;
2841 assert_eq!(update_events(&drain_events(&mut rx)), 0);
2842 }
2843
2844 use std::{future::Future, pin::Pin};
2847
2848 struct FakeHarvestRunner {
2852 calls: tokio::sync::mpsc::UnboundedSender<(String, PathBuf, PathBuf)>,
2853 outcome: Result<(), String>,
2854 }
2855
2856 impl HarvestRunner for FakeHarvestRunner {
2857 fn run(
2858 &self,
2859 prompt: String,
2860 workspace: PathBuf,
2861 brain_path: PathBuf,
2862 ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>> {
2863 let _ = self.calls.send((prompt, workspace, brain_path));
2864 let outcome = self.outcome.clone();
2865 Box::pin(async move {
2866 outcome.map_err(|e| anyhow::anyhow!(e))
2867 })
2868 }
2869 }
2870
2871 fn init_diff_repo(feature_branch: &str, extra_file: Option<(&str, &str)>) -> std::path::PathBuf {
2876 let dir = tempfile::tempdir().unwrap().keep();
2877 let run = |args: &[&str]| {
2878 std::process::Command::new("git")
2879 .args(["-C", dir.to_str().unwrap()])
2880 .args(args)
2881 .output()
2882 .unwrap()
2883 };
2884 run(&["init", "-q", "-b", "main"]);
2885 run(&["config", "user.email", "test@example.com"]);
2886 run(&["config", "user.name", "Test"]);
2887 std::fs::write(dir.join("README.md"), "x").unwrap();
2888 run(&["add", "."]);
2889 run(&["commit", "-q", "-m", "init"]);
2890 run(&["checkout", "-q", "-b", feature_branch]);
2891 if let Some((name, contents)) = extra_file {
2892 std::fs::write(dir.join(name), contents).unwrap();
2893 run(&["add", name]);
2894 run(&["commit", "-q", "-m", "feature work"]);
2895 }
2896 dir
2897 }
2898
2899 fn nonexistent_config_path() -> std::path::PathBuf {
2903 tempfile::tempdir().unwrap().keep().join("nonexistent-ninox-config.toml")
2904 }
2905
2906 #[allow(clippy::await_holding_lock)]
2911 #[tokio::test]
2912 async fn metadata_sync_triggers_brain_harvest_exactly_once_on_pr_detection() {
2913 use crate::{config::ENV_TEST_GUARD, store::Store};
2914
2915 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2916 let prior = std::env::var("NINOX_CONFIG").ok();
2917 std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
2918
2919 let repo = init_diff_repo("feature-1", Some(("src.rs", "fn main() {}\n")));
2920 let workspace = repo.to_str().unwrap().to_string();
2921
2922 let sessions_dir = tempfile::tempdir().unwrap();
2923 let meta = serde_json::json!({"agentReportedPrNumber": "9"});
2924 std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
2925
2926 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2927 store.upsert_session(&test_session("s1", &workspace)).unwrap();
2928 let engine = Engine::new(store.clone());
2929
2930 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2931 let runner = Arc::new(FakeHarvestRunner { calls: tx, outcome: Ok(()) });
2932 let poller = Poller::new_with_harvest_runner(engine, runner);
2933
2934 poller.sync_sessions_metadata(sessions_dir.path()).await;
2935
2936 let (prompt, ..) = tokio::time::timeout(Duration::from_secs(2), rx.recv())
2937 .await
2938 .expect("harvest should be attempted")
2939 .expect("channel should not be closed");
2940 assert!(prompt.contains("src.rs") && prompt.contains("fn main"), "prompt must include the diff");
2941
2942 poller.sync_sessions_metadata(sessions_dir.path()).await;
2945 assert!(rx.try_recv().is_err(), "harvest must fire exactly once per session");
2946
2947 match prior {
2948 Some(v) => std::env::set_var("NINOX_CONFIG", v),
2949 None => std::env::remove_var("NINOX_CONFIG"),
2950 }
2951 }
2952
2953 #[allow(clippy::await_holding_lock)]
2958 #[tokio::test]
2959 async fn metadata_sync_brain_harvest_prefers_session_catalogue_path_over_default() {
2960 use crate::{config::ENV_TEST_GUARD, store::Store};
2961
2962 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2963 let prior = std::env::var("NINOX_CONFIG").ok();
2964 std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
2965
2966 let repo = init_diff_repo("feature-catalogue", Some(("src.rs", "fn main() {}\n")));
2967 let workspace = repo.to_str().unwrap().to_string();
2968
2969 let sessions_dir = tempfile::tempdir().unwrap();
2970 let meta = serde_json::json!({"agentReportedPrNumber": "13"});
2971 std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
2972
2973 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2974 let mut session = test_session("s1", &workspace);
2975 session.catalogue_path = Some("/custom/brain-catalogue".to_string());
2976 store.upsert_session(&session).unwrap();
2977 let engine = Engine::new(store.clone());
2978
2979 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2980 let runner = Arc::new(FakeHarvestRunner { calls: tx, outcome: Ok(()) });
2981 let poller = Poller::new_with_harvest_runner(engine, runner);
2982
2983 poller.sync_sessions_metadata(sessions_dir.path()).await;
2984
2985 let (_, _, brain_path) = tokio::time::timeout(Duration::from_secs(2), rx.recv())
2986 .await
2987 .expect("harvest should be attempted")
2988 .expect("channel should not be closed");
2989 assert_eq!(
2990 brain_path, PathBuf::from("/custom/brain-catalogue"),
2991 "harvest must target the session's own catalogue, not the global default",
2992 );
2993
2994 match prior {
2995 Some(v) => std::env::set_var("NINOX_CONFIG", v),
2996 None => std::env::remove_var("NINOX_CONFIG"),
2997 }
2998 }
2999
3000 #[allow(clippy::await_holding_lock)]
3003 #[tokio::test]
3004 async fn metadata_sync_skips_brain_harvest_when_disabled() {
3005 use crate::{config::ENV_TEST_GUARD, store::Store};
3006
3007 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3008 let config_dir = tempfile::tempdir().unwrap();
3009 let config_path = config_dir.path().join("config.toml");
3010 std::fs::write(&config_path, "port = 8080\nfont_size = 13.0\n\n[brain_harvest]\nenabled = false\n").unwrap();
3011 let prior = std::env::var("NINOX_CONFIG").ok();
3012 std::env::set_var("NINOX_CONFIG", &config_path);
3013
3014 let repo = init_diff_repo("feature-2", Some(("src.rs", "fn main() {}\n")));
3015 let workspace = repo.to_str().unwrap().to_string();
3016
3017 let sessions_dir = tempfile::tempdir().unwrap();
3018 let meta = serde_json::json!({"agentReportedPrNumber": "10"});
3019 std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
3020
3021 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3022 store.upsert_session(&test_session("s1", &workspace)).unwrap();
3023 let engine = Engine::new(store.clone());
3024
3025 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3026 let runner = Arc::new(FakeHarvestRunner { calls: tx, outcome: Ok(()) });
3027 let poller = Poller::new_with_harvest_runner(engine, runner);
3028
3029 poller.sync_sessions_metadata(sessions_dir.path()).await;
3030
3031 assert!(rx.try_recv().is_err(), "harvest must not fire when brain_harvest.enabled = false");
3032 let session = store.get_session("s1").unwrap().unwrap();
3033 assert_eq!(session.pr_number, Some(10), "PR detection must be unaffected by the disabled harvest");
3034 assert!(matches!(session.status, SessionStatus::PrOpen));
3035
3036 match prior {
3037 Some(v) => std::env::set_var("NINOX_CONFIG", v),
3038 None => std::env::remove_var("NINOX_CONFIG"),
3039 }
3040 }
3041
3042 #[allow(clippy::await_holding_lock)]
3046 #[tokio::test]
3047 async fn metadata_sync_skips_brain_harvest_for_trivial_diff() {
3048 use crate::{config::ENV_TEST_GUARD, store::Store};
3049
3050 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3051 let prior = std::env::var("NINOX_CONFIG").ok();
3052 std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
3053
3054 let repo = init_diff_repo("feature-3", None);
3056 let workspace = repo.to_str().unwrap().to_string();
3057
3058 let sessions_dir = tempfile::tempdir().unwrap();
3059 let meta = serde_json::json!({"agentReportedPrNumber": "11"});
3060 std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
3061
3062 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3063 store.upsert_session(&test_session("s1", &workspace)).unwrap();
3064 let engine = Engine::new(store.clone());
3065
3066 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3067 let runner = Arc::new(FakeHarvestRunner { calls: tx, outcome: Ok(()) });
3068 let poller = Poller::new_with_harvest_runner(engine, runner);
3069
3070 poller.sync_sessions_metadata(sessions_dir.path()).await;
3071
3072 assert!(rx.try_recv().is_err(), "harvest must not fire for an empty diff");
3073
3074 match prior {
3075 Some(v) => std::env::set_var("NINOX_CONFIG", v),
3076 None => std::env::remove_var("NINOX_CONFIG"),
3077 }
3078 }
3079
3080 #[allow(clippy::await_holding_lock)]
3084 #[tokio::test]
3085 async fn metadata_sync_survives_a_failing_brain_harvest() {
3086 use crate::{config::ENV_TEST_GUARD, store::Store};
3087
3088 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3089 let prior = std::env::var("NINOX_CONFIG").ok();
3090 std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
3091
3092 let repo = init_diff_repo("feature-4", Some(("src.rs", "fn main() {}\n")));
3093 let workspace = repo.to_str().unwrap().to_string();
3094
3095 let sessions_dir = tempfile::tempdir().unwrap();
3096 let meta = serde_json::json!({"agentReportedPrNumber": "12"});
3097 std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
3098
3099 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3100 store.upsert_session(&test_session("s1", &workspace)).unwrap();
3101 let engine = Engine::new(store.clone());
3102
3103 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3104 let runner = Arc::new(FakeHarvestRunner {
3105 calls: tx,
3106 outcome: Err("simulated claude -p failure".to_string()),
3107 });
3108 let poller = Poller::new_with_harvest_runner(engine, runner);
3109
3110 poller.sync_sessions_metadata(sessions_dir.path()).await;
3111
3112 let session = store.get_session("s1").unwrap().unwrap();
3113 assert_eq!(session.pr_number, Some(12), "PR detection must succeed regardless of harvest outcome");
3114 assert!(matches!(session.status, SessionStatus::PrOpen));
3115
3116 tokio::time::timeout(Duration::from_secs(2), rx.recv())
3117 .await
3118 .expect("the failing harvest must still be attempted")
3119 .expect("channel should not be closed");
3120
3121 match prior {
3122 Some(v) => std::env::set_var("NINOX_CONFIG", v),
3123 None => std::env::remove_var("NINOX_CONFIG"),
3124 }
3125 }
3126
3127 #[derive(Clone, Default)]
3130 struct CapturedLogs(Arc<std::sync::Mutex<Vec<String>>>);
3131
3132 impl CapturedLogs {
3133 fn contains(&self, needle: &str) -> bool {
3134 self.0.lock().unwrap().iter().any(|m| m.contains(needle))
3135 }
3136 }
3137
3138 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CapturedLogs {
3139 fn on_event(&self, event: &tracing::Event<'_>, _ctx: tracing_subscriber::layer::Context<'_, S>) {
3140 struct Visitor(String);
3141 impl tracing::field::Visit for Visitor {
3142 fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
3143 if field.name() == "message" {
3144 self.0 = format!("{value:?}");
3145 }
3146 }
3147 }
3148 let mut visitor = Visitor(String::new());
3149 event.record(&mut visitor);
3150 self.0.lock().unwrap().push(visitor.0);
3151 }
3152 }
3153
3154 struct PanickingHarvestRunner;
3158
3159 impl HarvestRunner for PanickingHarvestRunner {
3160 fn run(
3161 &self,
3162 _prompt: String,
3163 _workspace: PathBuf,
3164 _brain_path: PathBuf,
3165 ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>> {
3166 Box::pin(async move { panic!("simulated harvest panic") })
3167 }
3168 }
3169
3170 #[allow(clippy::await_holding_lock)]
3175 #[tokio::test]
3176 async fn metadata_sync_logs_a_warning_when_harvest_task_panics() {
3177 use crate::{config::ENV_TEST_GUARD, store::Store};
3178 use tracing_subscriber::{layer::SubscriberExt, Registry};
3179
3180 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3181 let prior = std::env::var("NINOX_CONFIG").ok();
3182 std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
3183
3184 let logs = CapturedLogs::default();
3185 let _log_guard = tracing::subscriber::set_default(Registry::default().with(logs.clone()));
3186
3187 let repo = init_diff_repo("feature-panic", Some(("src.rs", "fn main() {}\n")));
3188 let workspace = repo.to_str().unwrap().to_string();
3189
3190 let sessions_dir = tempfile::tempdir().unwrap();
3191 let meta = serde_json::json!({"agentReportedPrNumber": "20"});
3192 std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
3193
3194 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3195 store.upsert_session(&test_session("s1", &workspace)).unwrap();
3196 let engine = Engine::new(store.clone());
3197
3198 let poller = Poller::new_with_harvest_runner(engine, Arc::new(PanickingHarvestRunner));
3199 poller.sync_sessions_metadata(sessions_dir.path()).await;
3200
3201 let deadline = std::time::Instant::now() + Duration::from_secs(5);
3205 while !logs.contains("brain harvest task panicked") {
3206 assert!(std::time::Instant::now() < deadline, "timed out waiting for the panic to be logged");
3207 tokio::time::sleep(Duration::from_millis(20)).await;
3208 }
3209
3210 match prior {
3211 Some(v) => std::env::set_var("NINOX_CONFIG", v),
3212 None => std::env::remove_var("NINOX_CONFIG"),
3213 }
3214 }
3215
3216 struct OverlapDetectingHarvestRunner {
3221 calls: tokio::sync::mpsc::UnboundedSender<()>,
3222 active: Arc<std::sync::atomic::AtomicUsize>,
3223 overlapped: Arc<std::sync::atomic::AtomicBool>,
3224 }
3225
3226 impl HarvestRunner for OverlapDetectingHarvestRunner {
3227 fn run(
3228 &self,
3229 _prompt: String,
3230 _workspace: PathBuf,
3231 _brain_path: PathBuf,
3232 ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>> {
3233 let _ = self.calls.send(());
3234 let active = self.active.clone();
3235 let overlapped = self.overlapped.clone();
3236 Box::pin(async move {
3237 if active.fetch_add(1, std::sync::atomic::Ordering::SeqCst) > 0 {
3238 overlapped.store(true, std::sync::atomic::Ordering::SeqCst);
3239 }
3240 tokio::time::sleep(Duration::from_millis(50)).await;
3241 active.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
3242 Ok(())
3243 })
3244 }
3245 }
3246
3247 #[allow(clippy::await_holding_lock)]
3253 #[tokio::test]
3254 async fn concurrent_harvests_to_the_same_vault_do_not_overlap() {
3255 use crate::{config::ENV_TEST_GUARD, store::Store};
3256
3257 let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3258 let prior = std::env::var("NINOX_CONFIG").ok();
3259 std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
3260
3261 let repo1 = init_diff_repo("feature-vault-1", Some(("a.rs", "fn a() {}\n")));
3262 let repo2 = init_diff_repo("feature-vault-2", Some(("b.rs", "fn b() {}\n")));
3263
3264 let sessions_dir = tempfile::tempdir().unwrap();
3265 for (id, pr) in [("s1", "30"), ("s2", "31")] {
3266 let meta = serde_json::json!({"agentReportedPrNumber": pr});
3267 std::fs::write(sessions_dir.path().join(format!("{id}.json")), serde_json::to_string(&meta).unwrap()).unwrap();
3268 }
3269
3270 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3271 store.upsert_session(&test_session("s1", repo1.to_str().unwrap())).unwrap();
3272 store.upsert_session(&test_session("s2", repo2.to_str().unwrap())).unwrap();
3273 let engine = Engine::new(store.clone());
3274
3275 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3276 let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3277 let overlapped = Arc::new(std::sync::atomic::AtomicBool::new(false));
3278 let runner = Arc::new(OverlapDetectingHarvestRunner {
3279 calls: tx, active: active.clone(), overlapped: overlapped.clone(),
3280 });
3281 let poller = Poller::new_with_harvest_runner(engine, runner);
3282
3283 poller.sync_sessions_metadata(sessions_dir.path()).await;
3284
3285 for _ in 0..2 {
3286 tokio::time::timeout(Duration::from_secs(2), rx.recv())
3287 .await
3288 .expect("both harvests should be attempted")
3289 .expect("channel should not be closed");
3290 }
3291 tokio::time::sleep(Duration::from_millis(150)).await;
3294
3295 assert!(
3296 !overlapped.load(std::sync::atomic::Ordering::SeqCst),
3297 "concurrent harvests to the same vault must not run HarvestRunner::run concurrently",
3298 );
3299
3300 match prior {
3301 Some(v) => std::env::set_var("NINOX_CONFIG", v),
3302 None => std::env::remove_var("NINOX_CONFIG"),
3303 }
3304 }
3305
3306 #[test]
3312 fn vault_lock_treats_equivalent_paths_as_the_same_vault() {
3313 use crate::store::Store;
3314
3315 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3316 let engine = Engine::new(store);
3317 let poller = Poller::new_with_harvest_runner(engine, Arc::new(ClaudeHarvestRunner));
3318
3319 let dir = tempfile::tempdir().unwrap();
3320 let canonical = dir.path().to_path_buf();
3321 let with_trailing_slash = PathBuf::from(format!("{}/", canonical.display()));
3322
3323 let lock_a = poller.vault_lock(&canonical);
3324 let lock_b = poller.vault_lock(&with_trailing_slash);
3325
3326 assert!(
3327 Arc::ptr_eq(&lock_a, &lock_b),
3328 "syntactically different paths to the same physical vault must share one lock",
3329 );
3330 }
3331 #[tokio::test]
3337 async fn merge_detection_fires_for_terminated_session() {
3338 use crate::store::Store;
3339
3340 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3341 let mut s = test_session("w1", "/ws");
3342 s.status = SessionStatus::Terminated;
3343 s.orchestrator_id = Some("orch1".into());
3344 s.pr_number = Some(42);
3345 store.upsert_session(&s).unwrap();
3346
3347 let engine = Engine::new(store.clone());
3348 let mut rx = engine.subscribe();
3349 let poller = Poller::new(engine);
3350
3351 let handled = poller.handle_merge_detection(&s, 42, true).await;
3352 assert!(handled, "merge detection must run for a Terminated session");
3353
3354 let after = store.get_session("w1").unwrap().unwrap();
3355 assert!(matches!(after.status, SessionStatus::Done), "merged session transitions to Done");
3356 assert!(after.terminal_at.is_some(), "Done via merge detection must stamp terminal_at");
3357
3358 let events = drain_events(&mut rx);
3359 let merged_notifs = events.iter().filter(|e| matches!(
3360 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkerDone
3361 )).count();
3362 assert_eq!(merged_notifs, 1, "exactly one WorkerDone notification for the merge");
3363 }
3364
3365 #[tokio::test]
3370 async fn merge_detection_does_not_fire_twice_for_the_same_session() {
3371 use crate::store::Store;
3372
3373 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3374 let mut s = test_session("w1", "/ws");
3375 s.status = SessionStatus::PrOpen;
3376 s.orchestrator_id = Some("orch1".into());
3377 s.pr_number = Some(7);
3378 store.upsert_session(&s).unwrap();
3379
3380 let engine = Engine::new(store.clone());
3381 let mut rx = engine.subscribe();
3382 let poller = Poller::new(engine);
3383
3384 let first = poller.handle_merge_detection(&s, 7, true).await;
3385 assert!(first, "first tick handles the merge");
3386
3387 let updated = store.get_session("w1").unwrap().unwrap();
3390 let second = poller.handle_merge_detection(&updated, 7, true).await;
3391 assert!(!second, "an already-Done session must not re-fire merge detection");
3392
3393 let events = drain_events(&mut rx);
3394 let merged_notifs = events.iter().filter(|e| matches!(
3395 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkerDone
3396 )).count();
3397 assert_eq!(merged_notifs, 1, "no duplicate WorkerDone notification across ticks");
3398 }
3399
3400 #[tokio::test]
3401 async fn handle_merge_detection_is_noop_when_pr_not_merged() {
3402 use crate::store::Store;
3403
3404 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3405 let s = test_session("w1", "/ws");
3406 store.upsert_session(&s).unwrap();
3407 let engine = Engine::new(store.clone());
3408 let poller = Poller::new(engine);
3409
3410 let handled = poller.handle_merge_detection(&s, 1, false).await;
3411 assert!(!handled);
3412 assert!(matches!(store.get_session("w1").unwrap().unwrap().status, SessionStatus::Working));
3413 }
3414
3415 #[tokio::test]
3422 async fn sweep_retired_sessions_purges_only_past_the_retention_window() {
3423 use crate::store::Store;
3424
3425 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3426 let retention = SessionRetentionConfig { done_retention_days: 2 };
3427 let now = now_millis();
3428 let retention_ms = retention.retention_millis();
3429
3430 let mut expired = test_session("expired", "/ws");
3431 expired.status = SessionStatus::Done;
3432 expired.terminal_at = Some(now - retention_ms - 1_000);
3433 store.upsert_session(&expired).unwrap();
3434
3435 let mut fresh = test_session("fresh", "/ws");
3436 fresh.status = SessionStatus::Done;
3437 fresh.terminal_at = Some(now - 1_000);
3438 store.upsert_session(&fresh).unwrap();
3439
3440 let engine = Engine::new(store.clone());
3441 let mut rx = engine.subscribe();
3442 let poller = Poller::new(engine);
3443
3444 poller.sweep_retired_sessions(&retention).await;
3445
3446 assert!(store.get_session("expired").unwrap().is_none(), "past-retention session must be purged");
3447 assert!(store.get_session("fresh").unwrap().is_some(), "within-retention session must survive");
3448
3449 let events = drain_events(&mut rx);
3450 assert!(events.iter().any(|e| matches!(e, Event::SessionDone(id) if id == "expired")));
3451 }
3452
3453 #[tokio::test]
3458 async fn sweep_retired_sessions_purges_sessions_with_no_terminal_at_immediately() {
3459 use crate::store::Store;
3460
3461 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3462 let mut s = test_session("user-killed", "/ws");
3463 s.status = SessionStatus::Terminated;
3464 s.terminal_at = None;
3465 store.upsert_session(&s).unwrap();
3466
3467 let engine = Engine::new(store.clone());
3468 let poller = Poller::new(engine);
3469
3470 poller.sweep_retired_sessions(&SessionRetentionConfig::default()).await;
3471
3472 assert!(store.get_session("user-killed").unwrap().is_none());
3473 }
3474
3475 #[tokio::test]
3478 async fn sweep_retired_sessions_never_purges_orchestrator_sessions() {
3479 use crate::store::Store;
3480
3481 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3482 store.upsert_orchestrator(&crate::types::Orchestrator {
3483 id: "orch1".into(), name: "orch".into(), created_at: 0,
3484 }).unwrap();
3485 let mut s = test_session("orch1", "/ws");
3486 s.status = SessionStatus::Done;
3487 s.terminal_at = Some(0); store.upsert_session(&s).unwrap();
3489
3490 let engine = Engine::new(store.clone());
3491 let poller = Poller::new(engine);
3492
3493 poller.sweep_retired_sessions(&SessionRetentionConfig::default()).await;
3494
3495 assert!(store.get_session("orch1").unwrap().is_some(), "orchestrator sessions are never auto-purged");
3496 }
3497
3498 #[tokio::test]
3501 async fn sweep_retired_sessions_ignores_non_terminal_sessions() {
3502 use crate::store::Store;
3503
3504 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3505 let mut s = test_session("still-working", "/ws");
3506 s.status = SessionStatus::Working;
3507 s.terminal_at = None;
3508 store.upsert_session(&s).unwrap();
3509
3510 let engine = Engine::new(store.clone());
3511 let poller = Poller::new(engine);
3512
3513 poller.sweep_retired_sessions(&SessionRetentionConfig::default()).await;
3514
3515 assert!(store.get_session("still-working").unwrap().is_some());
3516 }
3517
3518 #[tokio::test]
3524 async fn sweep_retired_sessions_notifies_orchestrator_for_never_merge_detected_session() {
3525 use crate::store::Store;
3526
3527 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3528 let mut s = test_session("w1", "/ws");
3529 s.status = SessionStatus::Terminated; s.orchestrator_id = Some("orch1".into());
3531 s.terminal_at = Some(0); store.upsert_session(&s).unwrap();
3533
3534 let engine = Engine::new(store.clone());
3535 let mut rx = engine.subscribe();
3536 let poller = Poller::new(engine);
3537
3538 poller.sweep_retired_sessions(&SessionRetentionConfig::default()).await;
3539
3540 assert!(store.get_session("w1").unwrap().is_none(), "session must still be purged");
3541
3542 let events = drain_events(&mut rx);
3543 let retired_notifs = events.iter().filter(|e| matches!(
3544 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkerRetired
3545 && n.id == "retired-w1"
3546 )).count();
3547 assert_eq!(retired_notifs, 1, "orchestrator must be told its worker was retired");
3548 }
3549
3550 #[tokio::test]
3555 async fn sweep_retired_sessions_does_not_double_notify_a_merge_detected_session() {
3556 use crate::store::Store;
3557
3558 let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3559 let mut s = test_session("w1", "/ws");
3560 s.orchestrator_id = Some("orch1".into());
3561 s.pr_number = Some(7);
3562 store.upsert_session(&s).unwrap();
3563
3564 let engine = Engine::new(store.clone());
3565 let mut rx = engine.subscribe();
3566 let poller = Poller::new(engine);
3567
3568 assert!(poller.handle_merge_detection(&s, 7, true).await);
3571 drain_events(&mut rx); let mut done = store.get_session("w1").unwrap().unwrap();
3575 done.terminal_at = Some(0);
3576 store.upsert_session(&done).unwrap();
3577
3578 poller.sweep_retired_sessions(&SessionRetentionConfig::default()).await;
3579
3580 assert!(store.get_session("w1").unwrap().is_none(), "session must still be purged");
3581
3582 let events = drain_events(&mut rx);
3583 let retired_notifs = events.iter().filter(|e| matches!(
3584 e, Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkerRetired
3585 )).count();
3586 assert_eq!(retired_notifs, 0, "must not re-notify a session already told about its merge");
3587 }
3588}