use crate::{
config::{AppConfig, SessionRetentionConfig},
events::{Engine, Event},
github::{split_repo, CheckRun},
hooks,
lifecycle::{
brain_harvest::{self, ClaudeHarvestRunner, HarvestRunner},
enrichment::EnrichmentCache,
probe::is_pid_alive,
update_check::{self, CargoRegistryUpdateSource, UpdateSource},
usage,
},
types::{
CIStatus, Comment, Notification, NotificationKind, PrId, Session, SessionStatus, PR,
},
};
use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
use tokio_util::sync::CancellationToken;
type ContextSnapshot = (f64, Option<f64>, Option<u64>);
pub(crate) fn now_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
if let Some(s) = payload.downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"<non-string panic payload>".to_string()
}
}
pub struct Poller {
engine: Arc<Engine>,
enrichment_cache: Arc<std::sync::Mutex<EnrichmentCache>>,
context_cache: Arc<std::sync::Mutex<HashMap<String, ContextSnapshot>>>,
harvest_runner: Arc<dyn HarvestRunner>,
vault_locks: Arc<std::sync::Mutex<HashMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>>,
update_source: Arc<dyn UpdateSource>,
last_notified_update: Arc<std::sync::Mutex<Option<semver::Version>>>,
}
impl Poller {
pub fn new(engine: Arc<Engine>) -> Self {
Self::new_with_harvest_runner(engine, Arc::new(ClaudeHarvestRunner))
}
pub fn new_with_harvest_runner(engine: Arc<Engine>, harvest_runner: Arc<dyn HarvestRunner>) -> Self {
Self {
engine,
enrichment_cache: Arc::new(std::sync::Mutex::new(HashMap::new())),
context_cache: Arc::new(std::sync::Mutex::new(HashMap::new())),
harvest_runner,
vault_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
update_source: Arc::new(CargoRegistryUpdateSource),
last_notified_update: Arc::new(std::sync::Mutex::new(None)),
}
}
pub fn with_update_source(mut self, source: Arc<dyn UpdateSource>) -> Self {
self.update_source = source;
self
}
fn vault_lock(&self, path: &Path) -> Arc<tokio::sync::Mutex<()>> {
let key = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let mut locks = self.vault_locks.lock().unwrap_or_else(|e| e.into_inner());
locks
.entry(key)
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone()
}
pub async fn start(self, token: CancellationToken) {
let mut pid_interval = tokio::time::interval(Duration::from_secs(5));
let mut usage_interval = tokio::time::interval(Duration::from_secs(10));
let mut github_interval = tokio::time::interval(Duration::from_secs(30));
let mut update_interval = tokio::time::interval(Duration::from_secs(6 * 60 * 60));
github_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
usage_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
update_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tokio::select! {
_ = token.cancelled() => break,
_ = pid_interval.tick() => {
self.poll_pids().await;
self.poll_context_updates().await;
let retention = AppConfig::load()
.unwrap_or_default()
.session_retention;
self.sweep_retired_sessions(&retention).await;
}
_ = usage_interval.tick() => self.poll_usage().await,
_ = update_interval.tick() => self.poll_update_check().await,
_ = github_interval.tick() => {
self.poll_pr_reconciliation().await;
self.poll_github().await;
}
}
}
}
async fn poll_update_check(&self) {
let current_version = env!("CARGO_PKG_VERSION");
match update_check::check_for_update(self.update_source.as_ref(), "ninox", current_version).await {
Ok(Some(latest)) => {
let already_notified = {
let mut last = self.last_notified_update.lock().unwrap_or_else(|e| e.into_inner());
let already = last.as_ref() == Some(&latest);
*last = Some(latest.clone());
already
};
if !already_notified {
self.engine.emit(Event::Notification(Notification {
id: format!("update-available-{latest}"),
kind: NotificationKind::UpdateAvailable,
title: "Update available".to_string(),
body: format!("ninox {latest} is available — running {current_version}"),
session_id: None,
created_at: now_millis(),
}));
}
}
Ok(None) => {}
Err(e) => tracing::warn!("update check failed: {e}"),
}
}
async fn poll_pids(&self) {
self.sync_sessions_metadata(&AppConfig::sessions_dir()).await;
let Ok(sessions) = self.engine.store.list_sessions() else { return };
for mut session in sessions {
if matches!(session.status, SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted) {
continue;
}
if let Some(pid) = session.pid {
if !is_pid_alive(pid) {
session.status = SessionStatus::Terminated;
session.terminal_at = Some(now_millis());
let _ = self.engine.store.upsert_session(&session);
self.engine.emit(Event::SessionUpdated(session));
}
}
}
}
async fn sync_sessions_metadata(&self, sessions_dir: &std::path::Path) {
let Ok(sessions) = self.engine.store.list_sessions() else { return };
for mut session in sessions {
self.deliver_work_requests(&session, sessions_dir).await;
if matches!(session.status, SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted) {
continue;
}
let Ok(meta) = hooks::read_session_metadata(sessions_dir, &session.id) else {
continue;
};
if session.pr_number.is_none() {
if let Some(first) = meta.pr_reports.first() {
session.pr_number = Some(first.number);
session.status = SessionStatus::PrOpen;
let _ = self.engine.store.upsert_session(&session);
self.engine.emit(Event::SessionUpdated(session.clone()));
tracing::info!(
"session {} PR #{} detected via metadata hook",
session.id, first.number
);
self.trigger_brain_harvest(&session).await;
}
}
let Some(tracked) = session.pr_number else { continue };
for report in meta.pr_reports.iter().filter(|r| r.number != tracked) {
if let Ok(None) = self.engine.store.get_pr(report.number as i64) {
let url = report.url.clone().unwrap_or_else(|| {
format!("https://github.com/{}/pull/{}", session.repo, report.number)
});
let _ = self.engine.store.upsert_pr(&PR {
id: report.number as i64,
number: report.number,
title: String::new(),
url,
body: String::new(),
session_id: session.id.clone(),
});
}
}
let notified = hooks::read_notified_extra_prs(sessions_dir, &session.id);
let mut fresh: Vec<(u64, Option<String>)> = Vec::new();
for report in meta.pr_reports.iter()
.filter(|r| r.number != tracked && !notified.contains(&r.number))
{
self.engine.emit(Event::Notification(Notification {
id: format!("extra-pr-{}-{}", session.id, report.number),
kind: NotificationKind::ExtraPr,
title: format!("Extra PR — {}", session.name),
body: format!("#{} opened beyond tracked #{tracked}", report.number),
session_id: Some(session.id.clone()),
created_at: now_millis(),
}));
fresh.push((report.number, report.url.clone()));
}
if fresh.is_empty() {
continue;
}
let numbers: Vec<u64> = fresh.iter().map(|(n, _)| *n).collect();
if let Err(e) = hooks::mark_extra_prs_notified(sessions_dir, &session.id, &numbers) {
tracing::warn!("mark extra PRs notified for {}: {e}", session.id);
}
if let Some(orch) = session.orchestrator_id.clone() {
let msg = crate::lifecycle::reactions::format_extra_pr_reaction(
&session, tracked, &fresh,
);
if let Err(e) = self.engine.send_to_session(&orch, &msg).await {
tracing::warn!("send extra-PR reaction to orchestrator {orch}: {e}");
}
}
}
}
async fn trigger_brain_harvest(&self, session: &Session) {
let config = AppConfig::load().unwrap_or_default();
if !config.brain_harvest.enabled {
return;
}
let Some(workspace) = session.workspace_path.clone() else { return };
let workspace_path: PathBuf = workspace.into();
let brain_path: PathBuf = session.catalogue_path.clone()
.map(PathBuf::from)
.unwrap_or_else(|| config.resolved_brain_path());
let runner = self.harvest_runner.clone();
let session_id = session.id.clone();
let panic_session_id = session_id.clone();
let vault_lock = self.vault_lock(&brain_path);
let handle = tokio::spawn(async move {
let Some(diff) = brain_harvest::compute_nontrivial_diff(&workspace_path).await else {
tracing::info!("brain harvest skipped for {session_id}: no non-trivial diff");
return;
};
let prompt = brain_harvest::build_harvest_prompt(&session_id, &diff);
let _guard = vault_lock.lock().await;
if let Err(e) = runner.run(prompt, workspace_path, brain_path).await {
tracing::warn!("brain harvest failed for session {session_id}: {e}");
}
});
tokio::spawn(async move {
if let Err(join_err) = handle.await {
if join_err.is_panic() {
let payload = join_err.into_panic();
tracing::warn!(
"brain harvest task panicked for session {panic_session_id}: {}",
panic_message(payload.as_ref()),
);
} else {
tracing::warn!("brain harvest task for session {panic_session_id} was cancelled");
}
}
});
}
async fn deliver_work_requests(&self, session: &crate::types::Session, sessions_dir: &std::path::Path) {
let pending = match hooks::read_pending_work_requests(sessions_dir, &session.id) {
Ok(p) if !p.is_empty() => p,
_ => return,
};
for request in &pending {
self.engine.emit(Event::Notification(Notification {
id: format!("work-request-{}", request.id),
kind: NotificationKind::WorkRequested,
title: format!("Work requested — {}", session.name),
body: request.description.clone(),
session_id: Some(session.id.clone()),
created_at: now_millis(),
}));
if let Some(orch) = session.orchestrator_id.clone() {
let msg = crate::lifecycle::reactions::format_work_request_reaction(
session, &request.description,
);
if let Err(e) = self.engine.send_to_session(&orch, &msg).await {
tracing::warn!("send work request to orchestrator {orch}: {e}");
}
}
}
let ids: Vec<String> = pending.iter().map(|r| r.id.clone()).collect();
if let Err(e) = hooks::mark_work_requests_delivered(sessions_dir, &session.id, &ids) {
tracing::warn!("mark work requests delivered for {}: {e}", session.id);
}
}
async fn poll_usage(&self) {
let Ok(sessions) = self.engine.store.list_sessions() else { return };
for mut session in sessions {
if matches!(session.status, SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted) {
continue;
}
let Some(workspace) = session.workspace_path.clone() else { continue };
let Some(snapshot) = usage::ingest_usage_for_workspace(&workspace) else { continue };
let cost_changed = (session.cost_usd - snapshot.cost_usd).abs() > 1e-9;
let context_changed = session.context_tokens != Some(snapshot.context_tokens);
if !cost_changed && !context_changed {
continue;
}
session.cost_usd = snapshot.cost_usd;
session.context_tokens = Some(snapshot.context_tokens);
if session.model.is_none() {
session.model = snapshot.model;
}
let _ = self.engine.store.upsert_session(&session);
self.engine.emit(Event::SessionUpdated(session));
}
}
async fn poll_context_updates(&self) {
let Ok(sessions) = self.engine.store.list_sessions() else { return };
let mut changed = Vec::new();
{
let mut cache = self.context_cache.lock().unwrap();
for session in sessions {
let key = (session.cost_usd, session.context_used_pct, session.context_total_tokens);
if let Some(prev) = cache.insert(session.id.clone(), key) {
if prev != key {
changed.push(session);
}
}
}
}
for session in changed {
self.engine.emit(Event::SessionUpdated(session));
}
}
async fn poll_github(&self) {
let Some(gh) = &self.engine.github else { return };
let Ok(sessions) = self.engine.store.list_sessions() else { return };
for mut session in sessions {
if matches!(session.status, SessionStatus::Done) {
continue;
}
let Some(pr_number) = session.pr_number else { continue };
let mut found: Option<(String, u64, crate::github::PrStatus)> = None;
let mut last_err: Option<anyhow::Error> = None;
let mut attempted = false;
if !session.repo.is_empty() {
if let Some((owner, repo)) = split_repo(&session.repo) {
attempted = true;
match gh.get_pr_status(&owner, &repo, pr_number).await {
Ok(s) => found = Some((session.repo.clone(), pr_number, s)),
Err(e) => last_err = Some(e),
}
}
}
if found.is_none() {
if let Some(workspace) = session.workspace_path.clone() {
if let Some(branch) = crate::github::current_branch(&workspace) {
for repo_slug in crate::github::candidate_repos(&workspace) {
if repo_slug == session.repo {
continue; }
let Some((owner, repo)) = split_repo(&repo_slug) else { continue };
attempted = true;
let pr_ref = match gh.find_open_pr_for_branch(&owner, &repo, &branch).await {
Ok(Some(r)) => r,
Ok(None) => continue,
Err(e) => { last_err = Some(e); continue; }
};
match gh.get_pr_status(&owner, &repo, pr_ref.number).await {
Ok(s) => { found = Some((repo_slug, pr_ref.number, s)); break; }
Err(e) => { last_err = Some(e); continue; }
}
}
}
}
}
if !attempted {
continue; }
let Some((resolved_repo, pr_number, pr_status)) = found else {
if let Some(e) = last_err {
tracing::warn!("github pr status for {}: {e}", session.id);
}
self.notify_github_lookup_failed(&session);
continue;
};
self.clear_github_lookup_failed(&session.id);
if resolved_repo != session.repo || Some(pr_number) != session.pr_number {
tracing::info!(
"session {} repo/PR corrected {}#{:?} -> {resolved_repo}#{pr_number}",
session.id, session.repo, session.pr_number,
);
session.repo = resolved_repo;
session.pr_number = Some(pr_number);
let _ = self.engine.store.upsert_session(&session);
}
let Some((owner, repo)) = split_repo(&session.repo) else { continue };
let pr_id: PrId = pr_number as i64;
if self.handle_merge_detection(&session, pr_number, pr_status.merged).await {
continue; }
{
let pr = PR {
id: pr_id,
number: pr_number,
title: pr_status.title.clone(),
url: format!("https://github.com/{owner}/{repo}/pull/{pr_number}"),
body: String::new(),
session_id: session.id.clone(),
};
let _ = self.engine.store.upsert_pr(&pr);
self.engine.emit(Event::PrOpened { session_id: session.id.clone(), pr });
}
let checks = match gh.get_ci_checks(&owner, &repo, &pr_status.head_sha).await {
Ok(c) => c,
Err(e) => { tracing::warn!("github ci checks: {e}"); vec![] }
};
let ci = summarize_checks(pr_id, &checks);
let _ = self.engine.store.upsert_ci_status(&ci);
self.engine.emit(Event::CiUpdated { pr_id, status: ci.clone() });
let (newly_failing, ci_reaction_already_sent) = {
let mut cache = self.enrichment_cache.lock().unwrap();
let state = cache.entry(session.id.clone()).or_default();
let newly_failing = state.prev_failing.is_none_or(|p| p == 0)
&& ci.failing > 0;
state.prev_failing = Some(ci.failing);
let already_sent = state.ci_reaction_sent;
if newly_failing && !already_sent {
state.ci_reaction_sent = true;
}
if ci.failing == 0 {
state.ci_reaction_sent = false;
}
(newly_failing, already_sent)
};
if newly_failing && !ci_reaction_already_sent {
self.engine.emit(Event::Notification(Notification {
id: format!("ci-{}", session.id),
kind: NotificationKind::CiFailure,
title: format!("CI failing — {}", session.name),
body: format!("{}/{} checks failing", ci.failing, ci.total),
session_id: Some(session.id.clone()),
created_at: now_millis(),
}));
let failing_names: Vec<String> = checks.iter()
.filter(|c| c.conclusion.as_deref() == Some("failure")
|| c.conclusion.as_deref() == Some("timed_out"))
.map(|c| c.name.clone())
.collect();
let msg = crate::lifecycle::reactions::format_ci_reaction(
&session, &ci, &failing_names
);
if let Err(e) = self.engine.send_to_session(&session.id, &msg).await {
tracing::warn!("send ci reaction to {}: {e}", session.id);
}
}
let threads = match gh.get_review_threads(&owner, &repo, pr_number).await {
Ok(t) => t,
Err(e) => { tracing::warn!("github review threads: {e}"); vec![] }
};
let has_changes_requested = threads.iter().any(|t| t.state == "CHANGES_REQUESTED");
let (has_new, review_reaction_already_sent, new_comments) = {
let mut cache = self.enrichment_cache.lock().unwrap();
let state = cache.entry(session.id.clone()).or_default();
let mut has_new = false;
let mut new_comments: Vec<Comment> = Vec::new();
for thread in &threads {
if thread.state == "CHANGES_REQUESTED"
&& !state.seen_comment_ids.contains(&thread.id)
{
state.seen_comment_ids.insert(thread.id);
has_new = true;
let comment = Comment {
id: thread.id,
pr_id,
author: thread.author.clone(),
body: thread.body.clone(),
path: thread.path.clone(),
line: thread.line,
created_at: 0,
};
let _ = self.engine.store.upsert_comment(&comment);
self.engine.emit(Event::ReviewComment { pr_id, comment: comment.clone() });
new_comments.push(comment);
}
}
let already_sent = state.review_reaction_sent;
if has_new && !already_sent {
state.review_reaction_sent = true;
}
if !has_changes_requested {
state.review_reaction_sent = false;
}
(has_new, already_sent, new_comments)
};
let new_status = derive_session_status(&session.status, &pr_status, &ci, has_changes_requested);
let mut updated = session.clone();
updated.status = new_status;
if updated.status != session.status {
let _ = self.engine.store.upsert_session(&updated);
self.engine.emit(Event::SessionUpdated(updated.clone()));
}
if has_new && !review_reaction_already_sent {
self.engine.emit(Event::Notification(Notification {
id: format!("review-{}", session.id),
kind: NotificationKind::PrNeedsAttention,
title: format!("Review comments — {}", session.name),
body: "Changes requested on your PR".to_string(),
session_id: Some(session.id.clone()),
created_at: now_millis(),
}));
if !new_comments.is_empty() {
let msg = crate::lifecycle::reactions::format_review_reaction(
&session, &new_comments
);
if let Err(e) = self.engine.send_to_session(&session.id, &msg).await {
tracing::warn!("send review reaction to {}: {e}", session.id);
}
}
}
}
}
fn notify_github_lookup_failed(&self, session: &crate::types::Session) {
let already_notified = {
let mut cache = self.enrichment_cache.lock().unwrap();
let state = cache.entry(session.id.clone()).or_default();
let already = state.github_lookup_failed_notified;
state.github_lookup_failed_notified = true;
already
};
if already_notified {
return;
}
self.engine.emit(Event::Notification(Notification {
id: format!("github-lookup-failed-{}", session.id),
kind: NotificationKind::GithubLookupFailed,
title: format!("GitHub lookup failing — {}", session.name),
body: "PR status/CI/review polling failed against every configured remote"
.to_string(),
session_id: Some(session.id.clone()),
created_at: now_millis(),
}));
}
fn clear_github_lookup_failed(&self, session_id: &str) {
let mut cache = self.enrichment_cache.lock().unwrap();
if let Some(state) = cache.get_mut(session_id) {
state.github_lookup_failed_notified = false;
}
}
async fn poll_pr_reconciliation(&self) {
let Some(gh) = &self.engine.github else { return };
let Ok(sessions) = self.engine.store.list_sessions() else { return };
for mut session in sessions {
if session.pr_number.is_some() {
continue;
}
if matches!(session.status, SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted) {
continue;
}
let Some(workspace) = session.workspace_path.clone() else { continue };
let Some(branch) = crate::github::current_branch(&workspace) else { continue };
for repo_slug in crate::github::candidate_repos(&workspace) {
let Some((owner, repo)) = split_repo(&repo_slug) else { continue };
match gh.find_open_pr_for_branch(&owner, &repo, &branch).await {
Ok(Some(pr_ref)) => {
session.pr_number = Some(pr_ref.number);
session.repo = repo_slug.clone();
session.status = SessionStatus::PrOpen;
let _ = self.engine.store.upsert_session(&session);
self.engine.emit(Event::SessionUpdated(session.clone()));
tracing::info!(
"session {} PR #{} detected via reconciliation ({repo_slug}, branch {branch})",
session.id, pr_ref.number,
);
break;
}
Ok(None) => continue,
Err(e) => {
tracing::warn!("reconciliation lookup {repo_slug} branch {branch}: {e}");
continue;
}
}
}
}
}
async fn handle_merge_detection(
&self,
session: &Session,
pr_number: u64,
pr_merged: bool,
) -> bool {
if !pr_merged || matches!(session.status, SessionStatus::Done) {
return false;
}
self.engine.emit(Event::Notification(Notification {
id: format!("merged-{}", session.id),
kind: NotificationKind::WorkerDone,
title: format!("PR merged — {}", session.name),
body: format!("#{} merged successfully", pr_number),
session_id: Some(session.id.clone()),
created_at: now_millis(),
}));
if let Some(orch) = session.orchestrator_id.clone() {
let msg = crate::lifecycle::reactions::format_worker_done_reaction(session, pr_number);
if let Err(e) = self.engine.send_to_session(&orch, &msg).await {
tracing::warn!("send worker-done reaction to orchestrator {orch}: {e}");
}
}
if let Err(e) = self.engine.cleanup_session(&session.id).await {
tracing::warn!("cleanup_session {}: {e}", session.id);
}
{
let mut cache = self.enrichment_cache.lock().unwrap();
cache.remove(&session.id);
}
true
}
async fn sweep_retired_sessions(&self, retention: &SessionRetentionConfig) {
let Ok(sessions) = self.engine.store.list_sessions() else { return };
let Ok(orchestrators) = self.engine.store.list_orchestrators() else { return };
let orch_ids: std::collections::HashSet<&str> =
orchestrators.iter().map(|o| o.id.as_str()).collect();
let now = now_millis();
let retention_ms = retention.retention_millis();
for session in sessions {
if !matches!(session.status, SessionStatus::Done | SessionStatus::Terminated) {
continue;
}
if orch_ids.contains(session.id.as_str()) {
continue;
}
let expired = match session.terminal_at {
Some(t) => now.saturating_sub(t) >= retention_ms,
None => true,
};
if !expired {
continue;
}
if matches!(session.status, SessionStatus::Terminated) {
self.engine.emit(Event::Notification(Notification {
id: format!("retired-{}", session.id),
kind: NotificationKind::WorkerRetired,
title: format!("Worker retired — {}", session.name),
body: "Session cleaned up without a detected PR merge".to_string(),
session_id: Some(session.id.clone()),
created_at: now,
}));
if let Some(orch) = session.orchestrator_id.clone() {
let msg = crate::lifecycle::reactions::format_worker_retired_reaction(&session);
if let Err(e) = self.engine.send_to_session(&orch, &msg).await {
tracing::warn!("send worker-retired reaction to orchestrator {orch}: {e}");
}
}
}
if let Err(e) = self.engine.remove_session(&session.id).await {
tracing::warn!("purge retired session {}: {e}", session.id);
continue;
}
}
}
}
fn summarize_checks(pr_id: PrId, checks: &[CheckRun]) -> CIStatus {
let total = checks.len() as u32;
let failing = checks.iter().filter(|c| {
c.conclusion.as_deref() == Some("failure")
|| c.conclusion.as_deref() == Some("timed_out")
}).count() as u32;
let passing = checks.iter().filter(|c| {
c.conclusion.as_deref() == Some("success")
}).count() as u32;
let pending = total - failing - passing;
CIStatus { pr_id, total, failing, passing, pending }
}
fn derive_session_status(
current: &SessionStatus,
pr_status: &crate::github::PrStatus,
ci: &CIStatus,
has_changes_requested: bool,
) -> SessionStatus {
if matches!(current, SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted) {
return current.clone();
}
if pr_status.merged {
return SessionStatus::Done;
}
if ci.failing > 0 {
return SessionStatus::CiFailed;
}
if has_changes_requested {
return SessionStatus::ReviewPending;
}
if pr_status.mergeable == Some(true) && ci.failing == 0 && ci.pending == 0 {
return SessionStatus::Mergeable;
}
SessionStatus::PrOpen
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::SessionStatus;
#[test]
fn summarize_checks_counts_failures() {
let checks = vec![
CheckRun { name: "lint".into(), status: "completed".into(), conclusion: Some("success".into()) },
CheckRun { name: "test".into(), status: "completed".into(), conclusion: Some("failure".into()) },
CheckRun { name: "build".into(), status: "in_progress".into(), conclusion: None },
];
let ci = summarize_checks(1, &checks);
assert_eq!(ci.total, 3);
assert_eq!(ci.passing, 1);
assert_eq!(ci.failing, 1);
assert_eq!(ci.pending, 1);
}
#[test]
fn derive_status_merged_becomes_done() {
let pr = crate::github::PrStatus {
merged: true, state: "closed".into(), mergeable: None,
title: "t".into(), number: 1, head_sha: String::new(),
};
let ci = CIStatus { pr_id: 1, total: 0, failing: 0, passing: 0, pending: 0 };
let s = derive_session_status(&SessionStatus::PrOpen, &pr, &ci, false);
assert!(matches!(s, SessionStatus::Done));
}
#[test]
fn derive_status_ci_failure_overrides_open() {
let pr = crate::github::PrStatus {
merged: false, state: "open".into(), mergeable: Some(true),
title: "t".into(), number: 1, head_sha: String::new(),
};
let ci = CIStatus { pr_id: 1, total: 3, failing: 1, passing: 2, pending: 0 };
let s = derive_session_status(&SessionStatus::PrOpen, &pr, &ci, false);
assert!(matches!(s, SessionStatus::CiFailed));
}
#[test]
fn derive_status_all_green_becomes_mergeable() {
let pr = crate::github::PrStatus {
merged: false, state: "open".into(), mergeable: Some(true),
title: "t".into(), number: 1, head_sha: String::new(),
};
let ci = CIStatus { pr_id: 1, total: 3, failing: 0, passing: 3, pending: 0 };
let s = derive_session_status(&SessionStatus::PrOpen, &pr, &ci, false);
assert!(matches!(s, SessionStatus::Mergeable));
}
#[test]
fn derive_status_preserves_done() {
let pr = crate::github::PrStatus {
merged: false, state: "open".into(), mergeable: Some(true),
title: "t".into(), number: 1, head_sha: String::new(),
};
let ci = CIStatus { pr_id: 1, total: 0, failing: 0, passing: 0, pending: 0 };
let s = derive_session_status(&SessionStatus::Done, &pr, &ci, false);
assert!(matches!(s, SessionStatus::Done));
}
#[test]
fn derive_status_preserves_terminated() {
let pr = crate::github::PrStatus {
merged: true, state: "closed".into(), mergeable: None, title: "t".into(), number: 1, head_sha: String::new(),
};
let ci = CIStatus { pr_id: 1, total: 0, failing: 0, passing: 0, pending: 0 };
let s = derive_session_status(&SessionStatus::Terminated, &pr, &ci, false);
assert!(matches!(s, SessionStatus::Terminated)); }
#[test]
fn derive_status_changes_requested_becomes_review_pending() {
let pr = crate::github::PrStatus {
merged: false, state: "open".into(), mergeable: Some(true),
title: "t".into(), number: 1, head_sha: String::new(),
};
let ci = CIStatus { pr_id: 1, total: 3, failing: 0, passing: 3, pending: 0 };
let s = derive_session_status(&SessionStatus::PrOpen, &pr, &ci, true);
assert!(matches!(s, SessionStatus::ReviewPending));
}
fn test_session(id: &str, workspace: &str) -> crate::types::Session {
crate::types::Session {
id: id.into(), orchestrator_id: None, name: id.into(),
repo: String::new(), status: SessionStatus::Working,
agent_type: "claude-code".into(), cost_usd: 0.0, started_at: 0,
pr_number: None, pr_id: None,
workspace_path: Some(workspace.into()), pid: None,
model: None, context_tokens: None, catalogue_path: None,
context_used_pct: None, context_total_tokens: None, context_window_size: None,
claude_session_id: None,
summary: None,
terminal_at: None,
}
}
#[tokio::test]
async fn poll_pids_leaves_interrupted_sessions_alone() {
use crate::store::Store;
let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
let mut s = test_session("interrupted-1", "/ws");
s.status = SessionStatus::Interrupted;
s.pid = Some(999_999); store.upsert_session(&s).unwrap();
let engine = Engine::new(store.clone());
let poller = Poller::new(engine);
poller.poll_pids().await;
let after = store.get_session("interrupted-1").unwrap().unwrap();
assert!(
matches!(after.status, SessionStatus::Interrupted),
"poll_pids must not re-terminate an Interrupted session just because its stale pid is dead",
);
}
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn poll_usage_ingests_transcript_into_store_and_emits_update() {
use crate::{lifecycle::usage::{claude_project_slug, ENV_TEST_GUARD}, store::Store};
use std::io::Write;
let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
let projects_dir = tempfile::tempdir().unwrap();
let workspace = "/tmp/poller-usage-probe-workspace";
let project_dir = projects_dir.path().join(claude_project_slug(workspace));
std::fs::create_dir_all(&project_dir).unwrap();
let mut f = std::fs::File::create(project_dir.join("s.jsonl")).unwrap();
writeln!(
f,
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}}}}}}"#
).unwrap();
drop(f);
let prior = std::env::var("NINOX_CLAUDE_PROJECTS_DIR").ok();
std::env::set_var("NINOX_CLAUDE_PROJECTS_DIR", projects_dir.path());
let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
store.upsert_session(&test_session("s1", workspace)).unwrap();
let engine = Engine::new(store.clone());
let mut rx = engine.subscribe();
let poller = Poller::new(engine);
poller.poll_usage().await;
match prior {
Some(v) => std::env::set_var("NINOX_CLAUDE_PROJECTS_DIR", v),
None => std::env::remove_var("NINOX_CLAUDE_PROJECTS_DIR"),
}
let updated = store.get_session("s1").unwrap().unwrap();
assert!(updated.cost_usd > 0.0, "cost_usd should be ingested, not 0.0000");
assert_eq!(updated.context_tokens, Some(2 + 500 + 45000));
assert_eq!(updated.model.as_deref(), Some("claude-fable-5"));
let evt = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
.await
.expect("SessionUpdated should be emitted")
.unwrap();
assert!(matches!(evt, Event::SessionUpdated(s) if s.id == "s1" && s.cost_usd > 0.0));
}
#[tokio::test]
async fn poll_context_updates_emits_only_for_changed_sessions() {
use crate::store::Store;
let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
let mut s1 = test_session("s1", "/ws1");
let s2 = test_session("s2", "/ws2");
store.upsert_session(&s1).unwrap();
store.upsert_session(&s2).unwrap();
let engine = Engine::new(store.clone());
let mut rx = engine.subscribe();
let poller = Poller::new(engine);
poller.poll_context_updates().await;
let baseline_events = drain_events(&mut rx);
assert!(baseline_events.is_empty(), "no prior cached state means no change to report");
s1.context_used_pct = Some(42.0);
s1.cost_usd = 3.5;
store.upsert_session(&s1).unwrap();
poller.poll_context_updates().await;
let events = drain_events(&mut rx);
assert_eq!(events.len(), 1, "only the changed session should emit");
assert!(matches!(
&events[0],
Event::SessionUpdated(s) if s.id == "s1" && s.context_used_pct == Some(42.0) && s.cost_usd == 3.5
));
poller.poll_context_updates().await;
assert!(drain_events(&mut rx).is_empty());
}
fn drain_events(rx: &mut tokio::sync::broadcast::Receiver<Event>) -> Vec<Event> {
let mut events = Vec::new();
while let Ok(e) = rx.try_recv() {
events.push(e);
}
events
}
#[tokio::test]
async fn metadata_sync_adopts_first_pr_and_flags_every_extra_once() {
use crate::store::Store;
let sessions_dir = tempfile::tempdir().unwrap();
let meta = serde_json::json!({
"agentReportedPrNumber": "44",
"agentReportedPrUrl": "https://github.com/org/repo/pull/44",
"agentReportedPrs": [
{"number": "42", "url": "https://github.com/org/repo/pull/42"},
{"number": "43", "url": "https://github.com/org/repo/pull/43"},
{"number": "44", "url": "https://github.com/org/repo/pull/44"},
],
});
std::fs::write(
sessions_dir.path().join("s1.json"),
serde_json::to_string(&meta).unwrap(),
).unwrap();
let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
store.upsert_session(&test_session("s1", "/ws")).unwrap();
let engine = Engine::new(store.clone());
let mut rx = engine.subscribe();
let poller = Poller::new(engine);
poller.sync_sessions_metadata(sessions_dir.path()).await;
let session = store.get_session("s1").unwrap().unwrap();
assert_eq!(session.pr_number, Some(42), "first PR is the canonical one");
assert!(matches!(session.status, SessionStatus::PrOpen));
assert!(store.get_pr(43).unwrap().is_some(), "extra PR #43 recorded");
assert!(store.get_pr(44).unwrap().is_some(), "extra PR #44 recorded");
assert_eq!(
store.get_pr(43).unwrap().unwrap().url,
"https://github.com/org/repo/pull/43",
);
let events = drain_events(&mut rx);
let extra_notifs: Vec<_> = events.iter().filter(|e| matches!(
e, Event::Notification(n) if n.kind == crate::types::NotificationKind::ExtraPr
)).collect();
assert_eq!(extra_notifs.len(), 2, "one ExtraPr notification per extra PR");
poller.sync_sessions_metadata(sessions_dir.path()).await;
let events = drain_events(&mut rx);
assert!(
!events.iter().any(|e| matches!(e, Event::Notification(_))),
"extra PRs must not be re-notified on every tick",
);
}
#[tokio::test]
async fn metadata_sync_single_pr_has_no_extra_notifications() {
use crate::store::Store;
let sessions_dir = tempfile::tempdir().unwrap();
let meta = serde_json::json!({
"agentReportedPrNumber": "5",
"agentReportedPrUrl": "https://github.com/org/repo/pull/5",
});
std::fs::write(
sessions_dir.path().join("s1.json"),
serde_json::to_string(&meta).unwrap(),
).unwrap();
let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
store.upsert_session(&test_session("s1", "/ws")).unwrap();
let engine = Engine::new(store.clone());
let mut rx = engine.subscribe();
let poller = Poller::new(engine);
poller.sync_sessions_metadata(sessions_dir.path()).await;
let session = store.get_session("s1").unwrap().unwrap();
assert_eq!(session.pr_number, Some(5));
assert!(matches!(session.status, SessionStatus::PrOpen));
let events = drain_events(&mut rx);
assert!(!events.iter().any(|e| matches!(e, Event::Notification(_))));
}
#[tokio::test]
async fn metadata_sync_delivers_work_requests_exactly_once() {
use crate::store::Store;
let sessions_dir = tempfile::tempdir().unwrap();
hooks::append_work_request(sessions_dir.path(), "s1", "Migrate the config loader").unwrap();
let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
store.upsert_session(&test_session("s1", "/ws")).unwrap();
let engine = Engine::new(store.clone());
let mut rx = engine.subscribe();
let poller = Poller::new(engine);
poller.sync_sessions_metadata(sessions_dir.path()).await;
let events = drain_events(&mut rx);
let notif = events.iter().find_map(|e| match e {
Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkRequested => Some(n),
_ => None,
}).expect("WorkRequested notification emitted");
assert!(notif.body.contains("Migrate the config loader"));
assert_eq!(notif.session_id.as_deref(), Some("s1"));
assert!(
hooks::read_pending_work_requests(sessions_dir.path(), "s1").unwrap().is_empty(),
"delivered requests must leave the pending set",
);
poller.sync_sessions_metadata(sessions_dir.path()).await;
let events = drain_events(&mut rx);
assert!(
!events.iter().any(|e| matches!(e, Event::Notification(_))),
"delivered work requests must not fire again",
);
}
#[tokio::test]
async fn metadata_sync_delivers_work_requests_from_terminated_sessions() {
use crate::store::Store;
let sessions_dir = tempfile::tempdir().unwrap();
hooks::append_work_request(sessions_dir.path(), "s1", "Follow-up refactor").unwrap();
let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
let mut session = test_session("s1", "/ws");
session.status = SessionStatus::Terminated;
store.upsert_session(&session).unwrap();
let engine = Engine::new(store.clone());
let mut rx = engine.subscribe();
let poller = Poller::new(engine);
poller.sync_sessions_metadata(sessions_dir.path()).await;
let events = drain_events(&mut rx);
assert!(
events.iter().any(|e| matches!(
e, Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkRequested
)),
"work requests outlive their session",
);
}
#[tokio::test]
async fn extra_pr_ledger_row_backfills_after_notification_without_renotifying() {
use crate::store::Store;
let sessions_dir = tempfile::tempdir().unwrap();
let meta = serde_json::json!({
"agentReportedPrs": [
{"number": "7", "url": "https://github.com/org/repo/pull/7"},
{"number": "9", "url": "https://github.com/org/repo/pull/9"},
],
});
std::fs::write(
sessions_dir.path().join("s1.json"),
serde_json::to_string(&meta).unwrap(),
).unwrap();
hooks::mark_extra_prs_notified(sessions_dir.path(), "s1", &[9]).unwrap();
let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
store.upsert_session(&test_session("s1", "/ws")).unwrap();
let engine = Engine::new(store.clone());
let mut rx = engine.subscribe();
let poller = Poller::new(engine);
poller.sync_sessions_metadata(sessions_dir.path()).await;
assert!(
store.get_pr(9).unwrap().is_some(),
"already-notified extra PR must still get its ledger row backfilled",
);
let events = drain_events(&mut rx);
assert!(
!events.iter().any(|e| matches!(
e, Event::Notification(n) if n.kind == crate::types::NotificationKind::ExtraPr
)),
"backfilling the row must not re-notify",
);
}
#[tokio::test]
async fn extra_pr_detection_survives_cross_repo_pr_number_collision() {
use crate::store::Store;
let sessions_dir = tempfile::tempdir().unwrap();
let meta = serde_json::json!({
"agentReportedPrs": [
{"number": "7", "url": "https://github.com/org/repo-a/pull/7"},
{"number": "9", "url": "https://github.com/org/repo-a/pull/9"},
],
});
std::fs::write(
sessions_dir.path().join("s1.json"),
serde_json::to_string(&meta).unwrap(),
).unwrap();
let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
store.upsert_session(&test_session("s1", "/ws")).unwrap();
let other = PR {
id: 9, number: 9, title: "other repo's PR".into(),
url: "https://github.com/org/repo-b/pull/9".into(),
body: String::new(), session_id: "other".into(),
};
store.upsert_pr(&other).unwrap();
let engine = Engine::new(store.clone());
let mut rx = engine.subscribe();
let poller = Poller::new(engine);
poller.sync_sessions_metadata(sessions_dir.path()).await;
let events = drain_events(&mut rx);
assert!(
events.iter().any(|e| matches!(
e, Event::Notification(n) if n.kind == crate::types::NotificationKind::ExtraPr
)),
"the collision must not suppress the extra-PR alert",
);
let row = store.get_pr(9).unwrap().unwrap();
assert_eq!(row.session_id, "other", "the other repo's row must not be stolen");
assert_eq!(row.url, "https://github.com/org/repo-b/pull/9");
poller.sync_sessions_metadata(sessions_dir.path()).await;
let events = drain_events(&mut rx);
assert!(
!events.iter().any(|e| matches!(e, Event::Notification(_))),
"dedup must hold across ticks even without a prs row of our own",
);
}
#[tokio::test]
async fn poll_usage_leaves_sessions_without_workspace_or_usage_untouched() {
use crate::store::Store;
let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
let mut no_ws = test_session("no-ws", "/does/not/matter");
no_ws.workspace_path = None;
store.upsert_session(&no_ws).unwrap();
let engine = Engine::new(store.clone());
let poller = Poller::new(engine);
poller.poll_usage().await;
let unchanged = store.get_session("no-ws").unwrap().unwrap();
assert_eq!(unchanged.cost_usd, 0.0);
assert_eq!(unchanged.context_tokens, None);
}
#[derive(Default)]
struct FakeGithub {
pr_status_ok: std::sync::Mutex<HashMap<(String, String, u64), crate::github::PrStatus>>,
branch_matches: std::sync::Mutex<HashMap<(String, String, String), crate::github::PrRef>>,
calls: std::sync::Mutex<Vec<(String, String, u64)>>,
}
#[async_trait::async_trait]
impl crate::github::GithubApi for FakeGithub {
async fn get_pr_status(&self, owner: &str, repo: &str, pr_number: u64) -> anyhow::Result<crate::github::PrStatus> {
self.calls.lock().unwrap().push((owner.to_string(), repo.to_string(), pr_number));
self.pr_status_ok.lock().unwrap()
.get(&(owner.to_string(), repo.to_string(), pr_number))
.cloned()
.ok_or_else(|| anyhow::anyhow!("404 for {owner}/{repo}#{pr_number}"))
}
async fn get_ci_checks(&self, _owner: &str, _repo: &str, _head_sha: &str) -> anyhow::Result<Vec<CheckRun>> {
Ok(vec![])
}
async fn get_review_threads(&self, _owner: &str, _repo: &str, _pr_number: u64) -> anyhow::Result<Vec<crate::github::ReviewThread>> {
Ok(vec![])
}
async fn find_open_pr_for_branch(&self, owner: &str, repo: &str, branch: &str) -> anyhow::Result<Option<crate::github::PrRef>> {
Ok(self.branch_matches.lock().unwrap()
.get(&(owner.to_string(), repo.to_string(), branch.to_string()))
.cloned())
}
}
fn init_git_repo(branch: &str, remotes: &[(&str, &str)]) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let workspace = dir.path().to_string_lossy().to_string();
let run = |args: &[&str]| {
let status = std::process::Command::new("git")
.args(["-C", &workspace]).args(args).status().unwrap();
assert!(status.success(), "git {args:?} failed");
};
run(&["init", "-q"]);
run(&["config", "user.email", "test@example.com"]);
run(&["config", "user.name", "Test"]);
run(&["commit", "--allow-empty", "-q", "-m", "init"]);
run(&["checkout", "-q", "-b", branch]);
for (name, url) in remotes {
run(&["remote", "add", name, url]);
}
dir
}
fn github_engine(store: std::sync::Arc<crate::store::Store>, gh: std::sync::Arc<FakeGithub>) -> std::sync::Arc<Engine> {
Engine::new_with_github_api(store, gh as std::sync::Arc<dyn crate::github::GithubApi>)
}
#[tokio::test]
async fn poll_pr_reconciliation_adopts_pr_found_without_wrapper_metadata() {
use crate::store::Store;
let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
let workspace = repo_dir.path().to_string_lossy().to_string();
let fake = std::sync::Arc::new(FakeGithub::default());
fake.branch_matches.lock().unwrap().insert(
("Owner".to_string(), "repo".to_string(), "worker-branch".to_string()),
crate::github::PrRef { number: 77, url: "https://github.com/Owner/repo/pull/77".into() },
);
let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
store.upsert_session(&test_session("s1", &workspace)).unwrap();
let engine = github_engine(store.clone(), fake);
let poller = Poller::new(engine);
poller.poll_pr_reconciliation().await;
let session = store.get_session("s1").unwrap().unwrap();
assert_eq!(session.pr_number, Some(77), "no wrapper metadata was ever written — reconciliation must still find it");
assert!(matches!(session.status, SessionStatus::PrOpen));
assert_eq!(session.repo, "Owner/repo");
}
#[tokio::test]
async fn poll_pr_reconciliation_leaves_sessions_with_no_matching_pr_untouched() {
use crate::store::Store;
let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
let workspace = repo_dir.path().to_string_lossy().to_string();
let fake = std::sync::Arc::new(FakeGithub::default());
let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
store.upsert_session(&test_session("s1", &workspace)).unwrap();
let engine = github_engine(store.clone(), fake);
let poller = Poller::new(engine);
poller.poll_pr_reconciliation().await;
let session = store.get_session("s1").unwrap().unwrap();
assert_eq!(session.pr_number, None);
}
#[tokio::test]
async fn poll_github_falls_back_to_other_remote_when_recorded_repo_404s() {
use crate::store::Store;
let repo_dir = init_git_repo("worker-branch", &[
("origin", "https://github.com/OwnerA/repoA.git"),
("mirror", "https://github.com/OwnerB/repoB.git"),
]);
let workspace = repo_dir.path().to_string_lossy().to_string();
let fake = std::sync::Arc::new(FakeGithub::default());
fake.branch_matches.lock().unwrap().insert(
("OwnerB".to_string(), "repoB".to_string(), "worker-branch".to_string()),
crate::github::PrRef { number: 99, url: "https://github.com/OwnerB/repoB/pull/99".into() },
);
fake.pr_status_ok.lock().unwrap().insert(
("OwnerB".to_string(), "repoB".to_string(), 99),
crate::github::PrStatus {
merged: false, state: "open".into(), mergeable: Some(true),
title: "t".into(), number: 99, head_sha: "abc".into(),
},
);
let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
let mut session = test_session("s1", &workspace);
session.repo = "OwnerA/repoA".into();
session.pr_number = Some(50);
store.upsert_session(&session).unwrap();
let engine = github_engine(store.clone(), fake.clone());
let poller = Poller::new(engine);
poller.poll_github().await;
let updated = store.get_session("s1").unwrap().unwrap();
assert_eq!(updated.repo, "OwnerB/repoB", "session.repo must self-heal to the remote that actually has the PR");
assert_eq!(updated.pr_number, Some(99), "must adopt the mirror's own PR number, not reuse the tracked repo's number");
let calls = fake.calls.lock().unwrap().clone();
assert_eq!(
calls,
vec![("OwnerA".to_string(), "repoA".to_string(), 50), ("OwnerB".to_string(), "repoB".to_string(), 99)],
"the repo on record must be tried first (by its own number), the mirror only as a branch-matched fallback",
);
}
#[tokio::test]
async fn poll_github_does_not_adopt_an_unrelated_pr_that_shares_the_tracked_number() {
use crate::store::Store;
let repo_dir = init_git_repo("worker-branch", &[
("origin", "https://github.com/OwnerA/repoA.git"),
("mirror", "https://github.com/OwnerB/repoB.git"),
]);
let workspace = repo_dir.path().to_string_lossy().to_string();
let fake = std::sync::Arc::new(FakeGithub::default());
fake.pr_status_ok.lock().unwrap().insert(
("OwnerB".to_string(), "repoB".to_string(), 50),
crate::github::PrStatus {
merged: false, state: "open".into(), mergeable: Some(true),
title: "someone else's unrelated PR".into(), number: 50, head_sha: "zzz".into(),
},
);
let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
let mut session = test_session("s1", &workspace);
session.repo = "OwnerA/repoA".into();
session.pr_number = Some(50);
store.upsert_session(&session).unwrap();
let engine = github_engine(store.clone(), fake.clone());
let poller = Poller::new(engine);
poller.poll_github().await;
let updated = store.get_session("s1").unwrap().unwrap();
assert_eq!(updated.repo, "OwnerA/repoA", "must not adopt the mirror just because it happens to have a same-numbered PR");
assert_eq!(updated.pr_number, Some(50));
let calls = fake.calls.lock().unwrap().clone();
assert!(
!calls.contains(&("OwnerB".to_string(), "repoB".to_string(), 50)),
"must never retry the tracked number against another repo's get_pr_status: {calls:?}",
);
}
#[tokio::test]
async fn poll_github_notifies_once_when_every_remote_fails_and_rearms_after_recovery() {
use crate::store::Store;
let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/OwnerA/repoA.git")]);
let workspace = repo_dir.path().to_string_lossy().to_string();
let fake = std::sync::Arc::new(FakeGithub::default());
let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
let mut session = test_session("s1", &workspace);
session.repo = "OwnerA/repoA".into();
session.pr_number = Some(50);
store.upsert_session(&session).unwrap();
let engine = github_engine(store.clone(), fake.clone());
let mut rx = engine.subscribe();
let poller = Poller::new(engine);
poller.poll_github().await;
let events = drain_events(&mut rx);
let failures = |evs: &[Event]| evs.iter().filter(|e| matches!(
e, Event::Notification(n) if n.kind == crate::types::NotificationKind::GithubLookupFailed
)).count();
assert_eq!(failures(&events), 1, "first failure must notify");
poller.poll_github().await;
let events = drain_events(&mut rx);
assert_eq!(failures(&events), 0, "repeated failure must not re-notify");
fake.pr_status_ok.lock().unwrap().insert(
("OwnerA".to_string(), "repoA".to_string(), 50),
crate::github::PrStatus {
merged: false, state: "open".into(), mergeable: Some(true),
title: "t".into(), number: 50, head_sha: "abc".into(),
},
);
poller.poll_github().await;
fake.pr_status_ok.lock().unwrap().clear();
poller.poll_github().await;
let events = drain_events(&mut rx);
assert_eq!(failures(&events), 1, "must notify again after recovering and failing anew");
}
struct FakeUpdateSource(std::sync::Mutex<Option<semver::Version>>);
#[async_trait::async_trait]
impl UpdateSource for FakeUpdateSource {
async fn latest_version(&self, _package: &str) -> anyhow::Result<Option<semver::Version>> {
Ok(self.0.lock().unwrap().clone())
}
}
fn bare_engine() -> Arc<Engine> {
use crate::store::Store;
let store = Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
Engine::new(store)
}
fn update_events(evs: &[Event]) -> usize {
evs.iter().filter(|e| matches!(
e, Event::Notification(n) if n.kind == crate::types::NotificationKind::UpdateAvailable
)).count()
}
#[tokio::test]
async fn poll_update_check_notifies_once_then_again_for_a_newer_version() {
let engine = bare_engine();
let mut rx = engine.subscribe();
let source = Arc::new(FakeUpdateSource(std::sync::Mutex::new(Some(semver::Version::new(99, 0, 0)))));
let poller = Poller::new(engine).with_update_source(source.clone());
poller.poll_update_check().await;
assert_eq!(update_events(&drain_events(&mut rx)), 1, "first sighting of a newer version must notify");
poller.poll_update_check().await;
assert_eq!(update_events(&drain_events(&mut rx)), 0, "same version again must not re-notify");
*source.0.lock().unwrap() = Some(semver::Version::new(100, 0, 0));
poller.poll_update_check().await;
assert_eq!(update_events(&drain_events(&mut rx)), 1, "an even newer version must notify again");
}
#[tokio::test]
async fn poll_update_check_no_notification_when_already_current() {
let engine = bare_engine();
let mut rx = engine.subscribe();
let current: semver::Version = env!("CARGO_PKG_VERSION").parse().unwrap();
let source = Arc::new(FakeUpdateSource(std::sync::Mutex::new(Some(current))));
let poller = Poller::new(engine).with_update_source(source);
poller.poll_update_check().await;
assert_eq!(update_events(&drain_events(&mut rx)), 0);
}
#[tokio::test]
async fn poll_update_check_no_notification_when_source_has_nothing() {
let engine = bare_engine();
let mut rx = engine.subscribe();
let source = Arc::new(FakeUpdateSource(std::sync::Mutex::new(None)));
let poller = Poller::new(engine).with_update_source(source);
poller.poll_update_check().await;
assert_eq!(update_events(&drain_events(&mut rx)), 0);
}
use std::{future::Future, pin::Pin};
struct FakeHarvestRunner {
calls: tokio::sync::mpsc::UnboundedSender<(String, PathBuf, PathBuf)>,
outcome: Result<(), String>,
}
impl HarvestRunner for FakeHarvestRunner {
fn run(
&self,
prompt: String,
workspace: PathBuf,
brain_path: PathBuf,
) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>> {
let _ = self.calls.send((prompt, workspace, brain_path));
let outcome = self.outcome.clone();
Box::pin(async move {
outcome.map_err(|e| anyhow::anyhow!(e))
})
}
}
fn init_diff_repo(feature_branch: &str, extra_file: Option<(&str, &str)>) -> std::path::PathBuf {
let dir = tempfile::tempdir().unwrap().keep();
let run = |args: &[&str]| {
std::process::Command::new("git")
.args(["-C", dir.to_str().unwrap()])
.args(args)
.output()
.unwrap()
};
run(&["init", "-q", "-b", "main"]);
run(&["config", "user.email", "test@example.com"]);
run(&["config", "user.name", "Test"]);
std::fs::write(dir.join("README.md"), "x").unwrap();
run(&["add", "."]);
run(&["commit", "-q", "-m", "init"]);
run(&["checkout", "-q", "-b", feature_branch]);
if let Some((name, contents)) = extra_file {
std::fs::write(dir.join(name), contents).unwrap();
run(&["add", name]);
run(&["commit", "-q", "-m", "feature work"]);
}
dir
}
fn nonexistent_config_path() -> std::path::PathBuf {
tempfile::tempdir().unwrap().keep().join("nonexistent-ninox-config.toml")
}
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn metadata_sync_triggers_brain_harvest_exactly_once_on_pr_detection() {
use crate::{config::ENV_TEST_GUARD, store::Store};
let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
let prior = std::env::var("NINOX_CONFIG").ok();
std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
let repo = init_diff_repo("feature-1", Some(("src.rs", "fn main() {}\n")));
let workspace = repo.to_str().unwrap().to_string();
let sessions_dir = tempfile::tempdir().unwrap();
let meta = serde_json::json!({"agentReportedPrNumber": "9"});
std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
store.upsert_session(&test_session("s1", &workspace)).unwrap();
let engine = Engine::new(store.clone());
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let runner = Arc::new(FakeHarvestRunner { calls: tx, outcome: Ok(()) });
let poller = Poller::new_with_harvest_runner(engine, runner);
poller.sync_sessions_metadata(sessions_dir.path()).await;
let (prompt, ..) = tokio::time::timeout(Duration::from_secs(2), rx.recv())
.await
.expect("harvest should be attempted")
.expect("channel should not be closed");
assert!(prompt.contains("src.rs") && prompt.contains("fn main"), "prompt must include the diff");
poller.sync_sessions_metadata(sessions_dir.path()).await;
assert!(rx.try_recv().is_err(), "harvest must fire exactly once per session");
match prior {
Some(v) => std::env::set_var("NINOX_CONFIG", v),
None => std::env::remove_var("NINOX_CONFIG"),
}
}
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn metadata_sync_brain_harvest_prefers_session_catalogue_path_over_default() {
use crate::{config::ENV_TEST_GUARD, store::Store};
let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
let prior = std::env::var("NINOX_CONFIG").ok();
std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
let repo = init_diff_repo("feature-catalogue", Some(("src.rs", "fn main() {}\n")));
let workspace = repo.to_str().unwrap().to_string();
let sessions_dir = tempfile::tempdir().unwrap();
let meta = serde_json::json!({"agentReportedPrNumber": "13"});
std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
let mut session = test_session("s1", &workspace);
session.catalogue_path = Some("/custom/brain-catalogue".to_string());
store.upsert_session(&session).unwrap();
let engine = Engine::new(store.clone());
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let runner = Arc::new(FakeHarvestRunner { calls: tx, outcome: Ok(()) });
let poller = Poller::new_with_harvest_runner(engine, runner);
poller.sync_sessions_metadata(sessions_dir.path()).await;
let (_, _, brain_path) = tokio::time::timeout(Duration::from_secs(2), rx.recv())
.await
.expect("harvest should be attempted")
.expect("channel should not be closed");
assert_eq!(
brain_path, PathBuf::from("/custom/brain-catalogue"),
"harvest must target the session's own catalogue, not the global default",
);
match prior {
Some(v) => std::env::set_var("NINOX_CONFIG", v),
None => std::env::remove_var("NINOX_CONFIG"),
}
}
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn metadata_sync_skips_brain_harvest_when_disabled() {
use crate::{config::ENV_TEST_GUARD, store::Store};
let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
let config_dir = tempfile::tempdir().unwrap();
let config_path = config_dir.path().join("config.toml");
std::fs::write(&config_path, "port = 8080\nfont_size = 13.0\n\n[brain_harvest]\nenabled = false\n").unwrap();
let prior = std::env::var("NINOX_CONFIG").ok();
std::env::set_var("NINOX_CONFIG", &config_path);
let repo = init_diff_repo("feature-2", Some(("src.rs", "fn main() {}\n")));
let workspace = repo.to_str().unwrap().to_string();
let sessions_dir = tempfile::tempdir().unwrap();
let meta = serde_json::json!({"agentReportedPrNumber": "10"});
std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
store.upsert_session(&test_session("s1", &workspace)).unwrap();
let engine = Engine::new(store.clone());
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let runner = Arc::new(FakeHarvestRunner { calls: tx, outcome: Ok(()) });
let poller = Poller::new_with_harvest_runner(engine, runner);
poller.sync_sessions_metadata(sessions_dir.path()).await;
assert!(rx.try_recv().is_err(), "harvest must not fire when brain_harvest.enabled = false");
let session = store.get_session("s1").unwrap().unwrap();
assert_eq!(session.pr_number, Some(10), "PR detection must be unaffected by the disabled harvest");
assert!(matches!(session.status, SessionStatus::PrOpen));
match prior {
Some(v) => std::env::set_var("NINOX_CONFIG", v),
None => std::env::remove_var("NINOX_CONFIG"),
}
}
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn metadata_sync_skips_brain_harvest_for_trivial_diff() {
use crate::{config::ENV_TEST_GUARD, store::Store};
let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
let prior = std::env::var("NINOX_CONFIG").ok();
std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
let repo = init_diff_repo("feature-3", None);
let workspace = repo.to_str().unwrap().to_string();
let sessions_dir = tempfile::tempdir().unwrap();
let meta = serde_json::json!({"agentReportedPrNumber": "11"});
std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
store.upsert_session(&test_session("s1", &workspace)).unwrap();
let engine = Engine::new(store.clone());
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let runner = Arc::new(FakeHarvestRunner { calls: tx, outcome: Ok(()) });
let poller = Poller::new_with_harvest_runner(engine, runner);
poller.sync_sessions_metadata(sessions_dir.path()).await;
assert!(rx.try_recv().is_err(), "harvest must not fire for an empty diff");
match prior {
Some(v) => std::env::set_var("NINOX_CONFIG", v),
None => std::env::remove_var("NINOX_CONFIG"),
}
}
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn metadata_sync_survives_a_failing_brain_harvest() {
use crate::{config::ENV_TEST_GUARD, store::Store};
let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
let prior = std::env::var("NINOX_CONFIG").ok();
std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
let repo = init_diff_repo("feature-4", Some(("src.rs", "fn main() {}\n")));
let workspace = repo.to_str().unwrap().to_string();
let sessions_dir = tempfile::tempdir().unwrap();
let meta = serde_json::json!({"agentReportedPrNumber": "12"});
std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
store.upsert_session(&test_session("s1", &workspace)).unwrap();
let engine = Engine::new(store.clone());
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let runner = Arc::new(FakeHarvestRunner {
calls: tx,
outcome: Err("simulated claude -p failure".to_string()),
});
let poller = Poller::new_with_harvest_runner(engine, runner);
poller.sync_sessions_metadata(sessions_dir.path()).await;
let session = store.get_session("s1").unwrap().unwrap();
assert_eq!(session.pr_number, Some(12), "PR detection must succeed regardless of harvest outcome");
assert!(matches!(session.status, SessionStatus::PrOpen));
tokio::time::timeout(Duration::from_secs(2), rx.recv())
.await
.expect("the failing harvest must still be attempted")
.expect("channel should not be closed");
match prior {
Some(v) => std::env::set_var("NINOX_CONFIG", v),
None => std::env::remove_var("NINOX_CONFIG"),
}
}
#[derive(Clone, Default)]
struct CapturedLogs(Arc<std::sync::Mutex<Vec<String>>>);
impl CapturedLogs {
fn contains(&self, needle: &str) -> bool {
self.0.lock().unwrap().iter().any(|m| m.contains(needle))
}
}
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CapturedLogs {
fn on_event(&self, event: &tracing::Event<'_>, _ctx: tracing_subscriber::layer::Context<'_, S>) {
struct Visitor(String);
impl tracing::field::Visit for Visitor {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
self.0 = format!("{value:?}");
}
}
}
let mut visitor = Visitor(String::new());
event.record(&mut visitor);
self.0.lock().unwrap().push(visitor.0);
}
}
struct PanickingHarvestRunner;
impl HarvestRunner for PanickingHarvestRunner {
fn run(
&self,
_prompt: String,
_workspace: PathBuf,
_brain_path: PathBuf,
) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>> {
Box::pin(async move { panic!("simulated harvest panic") })
}
}
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn metadata_sync_logs_a_warning_when_harvest_task_panics() {
use crate::{config::ENV_TEST_GUARD, store::Store};
use tracing_subscriber::{layer::SubscriberExt, Registry};
let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
let prior = std::env::var("NINOX_CONFIG").ok();
std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
let logs = CapturedLogs::default();
let _log_guard = tracing::subscriber::set_default(Registry::default().with(logs.clone()));
let repo = init_diff_repo("feature-panic", Some(("src.rs", "fn main() {}\n")));
let workspace = repo.to_str().unwrap().to_string();
let sessions_dir = tempfile::tempdir().unwrap();
let meta = serde_json::json!({"agentReportedPrNumber": "20"});
std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
store.upsert_session(&test_session("s1", &workspace)).unwrap();
let engine = Engine::new(store.clone());
let poller = Poller::new_with_harvest_runner(engine, Arc::new(PanickingHarvestRunner));
poller.sync_sessions_metadata(sessions_dir.path()).await;
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while !logs.contains("brain harvest task panicked") {
assert!(std::time::Instant::now() < deadline, "timed out waiting for the panic to be logged");
tokio::time::sleep(Duration::from_millis(20)).await;
}
match prior {
Some(v) => std::env::set_var("NINOX_CONFIG", v),
None => std::env::remove_var("NINOX_CONFIG"),
}
}
struct OverlapDetectingHarvestRunner {
calls: tokio::sync::mpsc::UnboundedSender<()>,
active: Arc<std::sync::atomic::AtomicUsize>,
overlapped: Arc<std::sync::atomic::AtomicBool>,
}
impl HarvestRunner for OverlapDetectingHarvestRunner {
fn run(
&self,
_prompt: String,
_workspace: PathBuf,
_brain_path: PathBuf,
) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>> {
let _ = self.calls.send(());
let active = self.active.clone();
let overlapped = self.overlapped.clone();
Box::pin(async move {
if active.fetch_add(1, std::sync::atomic::Ordering::SeqCst) > 0 {
overlapped.store(true, std::sync::atomic::Ordering::SeqCst);
}
tokio::time::sleep(Duration::from_millis(50)).await;
active.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
Ok(())
})
}
}
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn concurrent_harvests_to_the_same_vault_do_not_overlap() {
use crate::{config::ENV_TEST_GUARD, store::Store};
let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
let prior = std::env::var("NINOX_CONFIG").ok();
std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
let repo1 = init_diff_repo("feature-vault-1", Some(("a.rs", "fn a() {}\n")));
let repo2 = init_diff_repo("feature-vault-2", Some(("b.rs", "fn b() {}\n")));
let sessions_dir = tempfile::tempdir().unwrap();
for (id, pr) in [("s1", "30"), ("s2", "31")] {
let meta = serde_json::json!({"agentReportedPrNumber": pr});
std::fs::write(sessions_dir.path().join(format!("{id}.json")), serde_json::to_string(&meta).unwrap()).unwrap();
}
let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
store.upsert_session(&test_session("s1", repo1.to_str().unwrap())).unwrap();
store.upsert_session(&test_session("s2", repo2.to_str().unwrap())).unwrap();
let engine = Engine::new(store.clone());
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let overlapped = Arc::new(std::sync::atomic::AtomicBool::new(false));
let runner = Arc::new(OverlapDetectingHarvestRunner {
calls: tx, active: active.clone(), overlapped: overlapped.clone(),
});
let poller = Poller::new_with_harvest_runner(engine, runner);
poller.sync_sessions_metadata(sessions_dir.path()).await;
for _ in 0..2 {
tokio::time::timeout(Duration::from_secs(2), rx.recv())
.await
.expect("both harvests should be attempted")
.expect("channel should not be closed");
}
tokio::time::sleep(Duration::from_millis(150)).await;
assert!(
!overlapped.load(std::sync::atomic::Ordering::SeqCst),
"concurrent harvests to the same vault must not run HarvestRunner::run concurrently",
);
match prior {
Some(v) => std::env::set_var("NINOX_CONFIG", v),
None => std::env::remove_var("NINOX_CONFIG"),
}
}
#[test]
fn vault_lock_treats_equivalent_paths_as_the_same_vault() {
use crate::store::Store;
let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
let engine = Engine::new(store);
let poller = Poller::new_with_harvest_runner(engine, Arc::new(ClaudeHarvestRunner));
let dir = tempfile::tempdir().unwrap();
let canonical = dir.path().to_path_buf();
let with_trailing_slash = PathBuf::from(format!("{}/", canonical.display()));
let lock_a = poller.vault_lock(&canonical);
let lock_b = poller.vault_lock(&with_trailing_slash);
assert!(
Arc::ptr_eq(&lock_a, &lock_b),
"syntactically different paths to the same physical vault must share one lock",
);
}
#[tokio::test]
async fn merge_detection_fires_for_terminated_session() {
use crate::store::Store;
let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
let mut s = test_session("w1", "/ws");
s.status = SessionStatus::Terminated;
s.orchestrator_id = Some("orch1".into());
s.pr_number = Some(42);
store.upsert_session(&s).unwrap();
let engine = Engine::new(store.clone());
let mut rx = engine.subscribe();
let poller = Poller::new(engine);
let handled = poller.handle_merge_detection(&s, 42, true).await;
assert!(handled, "merge detection must run for a Terminated session");
let after = store.get_session("w1").unwrap().unwrap();
assert!(matches!(after.status, SessionStatus::Done), "merged session transitions to Done");
assert!(after.terminal_at.is_some(), "Done via merge detection must stamp terminal_at");
let events = drain_events(&mut rx);
let merged_notifs = events.iter().filter(|e| matches!(
e, Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkerDone
)).count();
assert_eq!(merged_notifs, 1, "exactly one WorkerDone notification for the merge");
}
#[tokio::test]
async fn merge_detection_does_not_fire_twice_for_the_same_session() {
use crate::store::Store;
let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
let mut s = test_session("w1", "/ws");
s.status = SessionStatus::PrOpen;
s.orchestrator_id = Some("orch1".into());
s.pr_number = Some(7);
store.upsert_session(&s).unwrap();
let engine = Engine::new(store.clone());
let mut rx = engine.subscribe();
let poller = Poller::new(engine);
let first = poller.handle_merge_detection(&s, 7, true).await;
assert!(first, "first tick handles the merge");
let updated = store.get_session("w1").unwrap().unwrap();
let second = poller.handle_merge_detection(&updated, 7, true).await;
assert!(!second, "an already-Done session must not re-fire merge detection");
let events = drain_events(&mut rx);
let merged_notifs = events.iter().filter(|e| matches!(
e, Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkerDone
)).count();
assert_eq!(merged_notifs, 1, "no duplicate WorkerDone notification across ticks");
}
#[tokio::test]
async fn handle_merge_detection_is_noop_when_pr_not_merged() {
use crate::store::Store;
let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
let s = test_session("w1", "/ws");
store.upsert_session(&s).unwrap();
let engine = Engine::new(store.clone());
let poller = Poller::new(engine);
let handled = poller.handle_merge_detection(&s, 1, false).await;
assert!(!handled);
assert!(matches!(store.get_session("w1").unwrap().unwrap().status, SessionStatus::Working));
}
#[tokio::test]
async fn sweep_retired_sessions_purges_only_past_the_retention_window() {
use crate::store::Store;
let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
let retention = SessionRetentionConfig { done_retention_days: 2 };
let now = now_millis();
let retention_ms = retention.retention_millis();
let mut expired = test_session("expired", "/ws");
expired.status = SessionStatus::Done;
expired.terminal_at = Some(now - retention_ms - 1_000);
store.upsert_session(&expired).unwrap();
let mut fresh = test_session("fresh", "/ws");
fresh.status = SessionStatus::Done;
fresh.terminal_at = Some(now - 1_000);
store.upsert_session(&fresh).unwrap();
let engine = Engine::new(store.clone());
let mut rx = engine.subscribe();
let poller = Poller::new(engine);
poller.sweep_retired_sessions(&retention).await;
assert!(store.get_session("expired").unwrap().is_none(), "past-retention session must be purged");
assert!(store.get_session("fresh").unwrap().is_some(), "within-retention session must survive");
let events = drain_events(&mut rx);
assert!(events.iter().any(|e| matches!(e, Event::SessionDone(id) if id == "expired")));
}
#[tokio::test]
async fn sweep_retired_sessions_purges_sessions_with_no_terminal_at_immediately() {
use crate::store::Store;
let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
let mut s = test_session("user-killed", "/ws");
s.status = SessionStatus::Terminated;
s.terminal_at = None;
store.upsert_session(&s).unwrap();
let engine = Engine::new(store.clone());
let poller = Poller::new(engine);
poller.sweep_retired_sessions(&SessionRetentionConfig::default()).await;
assert!(store.get_session("user-killed").unwrap().is_none());
}
#[tokio::test]
async fn sweep_retired_sessions_never_purges_orchestrator_sessions() {
use crate::store::Store;
let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
store.upsert_orchestrator(&crate::types::Orchestrator {
id: "orch1".into(), name: "orch".into(), created_at: 0,
}).unwrap();
let mut s = test_session("orch1", "/ws");
s.status = SessionStatus::Done;
s.terminal_at = Some(0); store.upsert_session(&s).unwrap();
let engine = Engine::new(store.clone());
let poller = Poller::new(engine);
poller.sweep_retired_sessions(&SessionRetentionConfig::default()).await;
assert!(store.get_session("orch1").unwrap().is_some(), "orchestrator sessions are never auto-purged");
}
#[tokio::test]
async fn sweep_retired_sessions_ignores_non_terminal_sessions() {
use crate::store::Store;
let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
let mut s = test_session("still-working", "/ws");
s.status = SessionStatus::Working;
s.terminal_at = None;
store.upsert_session(&s).unwrap();
let engine = Engine::new(store.clone());
let poller = Poller::new(engine);
poller.sweep_retired_sessions(&SessionRetentionConfig::default()).await;
assert!(store.get_session("still-working").unwrap().is_some());
}
#[tokio::test]
async fn sweep_retired_sessions_notifies_orchestrator_for_never_merge_detected_session() {
use crate::store::Store;
let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
let mut s = test_session("w1", "/ws");
s.status = SessionStatus::Terminated; s.orchestrator_id = Some("orch1".into());
s.terminal_at = Some(0); store.upsert_session(&s).unwrap();
let engine = Engine::new(store.clone());
let mut rx = engine.subscribe();
let poller = Poller::new(engine);
poller.sweep_retired_sessions(&SessionRetentionConfig::default()).await;
assert!(store.get_session("w1").unwrap().is_none(), "session must still be purged");
let events = drain_events(&mut rx);
let retired_notifs = events.iter().filter(|e| matches!(
e, Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkerRetired
&& n.id == "retired-w1"
)).count();
assert_eq!(retired_notifs, 1, "orchestrator must be told its worker was retired");
}
#[tokio::test]
async fn sweep_retired_sessions_does_not_double_notify_a_merge_detected_session() {
use crate::store::Store;
let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
let mut s = test_session("w1", "/ws");
s.orchestrator_id = Some("orch1".into());
s.pr_number = Some(7);
store.upsert_session(&s).unwrap();
let engine = Engine::new(store.clone());
let mut rx = engine.subscribe();
let poller = Poller::new(engine);
assert!(poller.handle_merge_detection(&s, 7, true).await);
drain_events(&mut rx);
let mut done = store.get_session("w1").unwrap().unwrap();
done.terminal_at = Some(0);
store.upsert_session(&done).unwrap();
poller.sweep_retired_sessions(&SessionRetentionConfig::default()).await;
assert!(store.get_session("w1").unwrap().is_none(), "session must still be purged");
let events = drain_events(&mut rx);
let retired_notifs = events.iter().filter(|e| matches!(
e, Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkerRetired
)).count();
assert_eq!(retired_notifs, 0, "must not re-notify a session already told about its merge");
}
}