Skip to main content

ninox_core/lifecycle/
poller.rs

1use crate::{
2    config::{AppConfig, SessionRetentionConfig},
3    events::{Engine, Event},
4    github::{split_repo, CheckRun},
5    hooks,
6    lifecycle::{
7        brain_harvest::{self, ClaudeHarvestRunner, HarvestRunner},
8        enrichment::EnrichmentCache,
9        probe::is_pid_alive,
10        usage,
11    },
12    types::{
13        CIStatus, Comment, Notification, NotificationKind, PrId, Session, SessionStatus, PR,
14    },
15};
16use std::{
17    collections::HashMap,
18    path::{Path, PathBuf},
19    sync::Arc,
20    time::Duration,
21};
22use tokio_util::sync::CancellationToken;
23
24/// Last-seen `(cost_usd, context_used_pct, context_total_tokens)` snapshot per session.
25/// Used by `poll_context_updates` to detect external changes.
26type ContextSnapshot = (f64, Option<f64>, Option<u64>);
27
28/// Unix epoch milliseconds "now" — used to stamp `Notification::created_at`
29/// and `Session::terminal_at`. `pub(crate)` so `events::cleanup_session` can
30/// stamp the same clock when it marks a session `Done`.
31pub(crate) fn now_millis() -> i64 {
32    std::time::SystemTime::now()
33        .duration_since(std::time::UNIX_EPOCH)
34        .map(|d| d.as_millis() as i64)
35        .unwrap_or(0)
36}
37
38/// Best-effort extraction of a human-readable message from a
39/// `JoinError::into_panic()` payload — used so a panicking `HarvestRunner`
40/// is diagnosable in logs instead of silently swallowed.
41fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
42    if let Some(s) = payload.downcast_ref::<&str>() {
43        s.to_string()
44    } else if let Some(s) = payload.downcast_ref::<String>() {
45        s.clone()
46    } else {
47        "<non-string panic payload>".to_string()
48    }
49}
50
51pub struct Poller {
52    engine:           Arc<Engine>,
53    enrichment_cache: Arc<std::sync::Mutex<EnrichmentCache>>,
54    /// Last-seen `(cost_usd, context_used_pct, context_total_tokens)` per
55    /// session, used solely to detect changes written externally by the
56    /// `ninox statusline` subcommand — see `poll_context_updates`.
57    context_cache:    Arc<std::sync::Mutex<HashMap<String, ContextSnapshot>>>,
58    /// Runs the brain-harvest subprocess (real `claude -p` in production).
59    /// Injectable so tests can fake success/failure without spawning a real
60    /// process — see `sync_sessions_metadata`'s `trigger_brain_harvest`.
61    harvest_runner:   Arc<dyn HarvestRunner>,
62    /// One lock per resolved brain vault path, created on first use. Held
63    /// across a harvest's `HarvestRunner::run` call so two sessions whose
64    /// harvests target the same vault (the common case: both on the global
65    /// default catalogue) never run their `claude -p` — and its `ninox
66    /// brain index` — concurrently against it. Never pruned: the number of
67    /// distinct vault paths in play is bounded by the number of configured
68    /// catalogues, not by session count.
69    vault_locks:      Arc<std::sync::Mutex<HashMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>>,
70}
71
72impl Poller {
73    pub fn new(engine: Arc<Engine>) -> Self {
74        Self::new_with_harvest_runner(engine, Arc::new(ClaudeHarvestRunner))
75    }
76
77    pub fn new_with_harvest_runner(engine: Arc<Engine>, harvest_runner: Arc<dyn HarvestRunner>) -> Self {
78        Self {
79            engine,
80            enrichment_cache: Arc::new(std::sync::Mutex::new(HashMap::new())),
81            context_cache:    Arc::new(std::sync::Mutex::new(HashMap::new())),
82            harvest_runner,
83            vault_locks:      Arc::new(std::sync::Mutex::new(HashMap::new())),
84        }
85    }
86
87    /// The (created-on-first-use) lock for a given vault path — see
88    /// `vault_locks`.
89    fn vault_lock(&self, path: &Path) -> Arc<tokio::sync::Mutex<()>> {
90        // Canonicalize so two syntactically different paths to the same
91        // physical vault (trailing slash, symlink) share one lock. Falls
92        // back to the raw path when it doesn't exist yet (e.g. a vault
93        // that hasn't been written to before) — that harvest still gets
94        // its own lock, just not deduplicated against a not-yet-existing
95        // twin, which can't race with anything yet either.
96        let key = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
97        let mut locks = self.vault_locks.lock().unwrap_or_else(|e| e.into_inner());
98        locks
99            .entry(key)
100            .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
101            .clone()
102    }
103
104    pub async fn start(self, token: CancellationToken) {
105        let mut pid_interval    = tokio::time::interval(Duration::from_secs(5));
106        let mut usage_interval  = tokio::time::interval(Duration::from_secs(10));
107        let mut github_interval = tokio::time::interval(Duration::from_secs(30));
108        // Prevent a missed tick from causing back-to-back polls.
109        github_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
110        usage_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
111        loop {
112            tokio::select! {
113                _ = token.cancelled()      => break,
114                _ = pid_interval.tick()    => {
115                    self.poll_pids().await;
116                    self.poll_context_updates().await;
117                    let retention = AppConfig::load()
118                        .unwrap_or_default()
119                        .session_retention;
120                    self.sweep_retired_sessions(&retention).await;
121                }
122                _ = usage_interval.tick()  => self.poll_usage().await,
123                _ = github_interval.tick() => {
124                    // Reconciliation first: a session whose PR the poller
125                    // hasn't adopted yet has no `pr_number` for `poll_github`
126                    // to enrich, so it must run before (not instead of) it.
127                    self.poll_pr_reconciliation().await;
128                    self.poll_github().await;
129                }
130            }
131        }
132    }
133
134    // ── PID liveness ────────────────────────────────────────────────────────
135
136    async fn poll_pids(&self) {
137        // Metadata first: a dying worker's last acts (PR create, work
138        // request) are processed before the reap below marks it Terminated.
139        self.sync_sessions_metadata(&AppConfig::sessions_dir()).await;
140
141        let Ok(sessions) = self.engine.store.list_sessions() else { return };
142        for mut session in sessions {
143            if matches!(session.status, SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted) {
144                continue;
145            }
146            if let Some(pid) = session.pid {
147                if !is_pid_alive(pid) {
148                    session.status = SessionStatus::Terminated;
149                    session.terminal_at = Some(now_millis());
150                    let _ = self.engine.store.upsert_session(&session);
151                    self.engine.emit(Event::SessionUpdated(session));
152                }
153            }
154        }
155    }
156
157    // ── Session metadata (wrapper hooks + `ninox request-work`) ────────────
158
159    /// One pass over every non-terminal session's metadata file: adopt the
160    /// first reported PR as the session's canonical one, record + notify any
161    /// PR opened beyond it, and deliver pending work requests to the
162    /// orchestrator. The dir is a parameter so tests can drive this against
163    /// a tempdir instead of `AppConfig::sessions_dir()`.
164    async fn sync_sessions_metadata(&self, sessions_dir: &std::path::Path) {
165        let Ok(sessions) = self.engine.store.list_sessions() else { return };
166        for mut session in sessions {
167            // Work requests are about *new* work, not this session — deliver
168            // them even when the requesting worker has already finished or
169            // died (a worker's last act is often "request follow-up, exit").
170            self.deliver_work_requests(&session, sessions_dir).await;
171
172            if matches!(session.status, SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted) {
173                continue;
174            }
175            let Ok(meta) = hooks::read_session_metadata(sessions_dir, &session.id) else {
176                continue;
177            };
178
179            // -- First reported PR becomes the session's tracked PR --
180            if session.pr_number.is_none() {
181                if let Some(first) = meta.pr_reports.first() {
182                    session.pr_number = Some(first.number);
183                    session.status    = SessionStatus::PrOpen;
184                    let _ = self.engine.store.upsert_session(&session);
185                    self.engine.emit(Event::SessionUpdated(session.clone()));
186                    tracing::info!(
187                        "session {} PR #{} detected via metadata hook",
188                        session.id, first.number
189                    );
190                    self.trigger_brain_harvest(&session).await;
191                }
192            }
193
194            // -- Every reported PR beyond the tracked one --
195            let Some(tracked) = session.pr_number else { continue };
196
197            // Ledger rows first, every tick: a store error at notification
198            // time must only defer the row to the next tick, never lose it.
199            // Only write when the row id (bare PR number — collides across
200            // repos) is free: never steal another session's row.
201            for report in meta.pr_reports.iter().filter(|r| r.number != tracked) {
202                if let Ok(None) = self.engine.store.get_pr(report.number as i64) {
203                    let url = report.url.clone().unwrap_or_else(|| {
204                        format!("https://github.com/{}/pull/{}", session.repo, report.number)
205                    });
206                    let _ = self.engine.store.upsert_pr(&PR {
207                        id:         report.number as i64,
208                        number:     report.number,
209                        title:      String::new(),
210                        url,
211                        body:       String::new(),
212                        session_id: session.id.clone(),
213                    });
214                }
215            }
216
217            // Notifications second, deduped via the poller-owned side file.
218            let notified = hooks::read_notified_extra_prs(sessions_dir, &session.id);
219            let mut fresh: Vec<(u64, Option<String>)> = Vec::new();
220            for report in meta.pr_reports.iter()
221                .filter(|r| r.number != tracked && !notified.contains(&r.number))
222            {
223                self.engine.emit(Event::Notification(Notification {
224                    id:         format!("extra-pr-{}-{}", session.id, report.number),
225                    kind:       NotificationKind::ExtraPr,
226                    title:      format!("Extra PR — {}", session.name),
227                    body:       format!("#{} opened beyond tracked #{tracked}", report.number),
228                    session_id: Some(session.id.clone()),
229                    created_at: now_millis(),
230                }));
231                fresh.push((report.number, report.url.clone()));
232            }
233            if fresh.is_empty() {
234                continue;
235            }
236            let numbers: Vec<u64> = fresh.iter().map(|(n, _)| *n).collect();
237            if let Err(e) = hooks::mark_extra_prs_notified(sessions_dir, &session.id, &numbers) {
238                tracing::warn!("mark extra PRs notified for {}: {e}", session.id);
239            }
240            if let Some(orch) = session.orchestrator_id.clone() {
241                let msg = crate::lifecycle::reactions::format_extra_pr_reaction(
242                    &session, tracked, &fresh,
243                );
244                if let Err(e) = self.engine.send_to_session(&orch, &msg).await {
245                    tracing::warn!("send extra-PR reaction to orchestrator {orch}: {e}");
246                }
247            }
248        }
249    }
250
251    /// Fire a background brain-harvest attempt for a session whose PR was
252    /// just detected. Reuses the caller's `pr_number.is_none()` guard as its
253    /// only dedup — this is called from exactly one call site, itself
254    /// guaranteed to fire once per session lifetime, so no second dedup
255    /// layer is needed here.
256    ///
257    /// Diff computation (a couple of local `git` subprocess calls) AND the
258    /// `claude -p` subprocess itself both run inside a single `tokio::spawn`
259    /// — nothing about harvesting, however slow, may stall this poll tick's
260    /// processing of other sessions. Any failure (disabled config, no
261    /// workspace, trivial diff, or the subprocess itself failing) is logged
262    /// at most and never propagates back into `sync_sessions_metadata`. A
263    /// second, supervising spawn awaits the harvest task purely to log a
264    /// panic that would otherwise be silent.
265    async fn trigger_brain_harvest(&self, session: &Session) {
266        let config = AppConfig::load().unwrap_or_default();
267        if !config.brain_harvest.enabled {
268            return;
269        }
270        let Some(workspace) = session.workspace_path.clone() else { return };
271        let workspace_path: PathBuf = workspace.into();
272        // Prefer the session's own catalogue — set from that worker's
273        // `NINOX_BRAIN` at spawn time (see `main.rs::run_spawn`,
274        // `spawn_util::interactive_env_vars`) — over the global default, so
275        // the harvest writes to the same vault the worker itself thinks
276        // with, not always the default catalogue.
277        let brain_path: PathBuf = session.catalogue_path.clone()
278            .map(PathBuf::from)
279            .unwrap_or_else(|| config.resolved_brain_path());
280        let runner       = self.harvest_runner.clone();
281        let session_id    = session.id.clone();
282        let panic_session_id = session_id.clone();
283        let vault_lock   = self.vault_lock(&brain_path);
284
285        let handle = tokio::spawn(async move {
286            let Some(diff) = brain_harvest::compute_nontrivial_diff(&workspace_path).await else {
287                tracing::info!("brain harvest skipped for {session_id}: no non-trivial diff");
288                return;
289            };
290            let prompt = brain_harvest::build_harvest_prompt(&session_id, &diff);
291
292            // Serialize concurrent harvests that share a vault — two
293            // `claude -p` subprocesses running `ninox brain index` against
294            // the same vault at once can race on the index write.
295            let _guard = vault_lock.lock().await;
296            if let Err(e) = runner.run(prompt, workspace_path, brain_path).await {
297                tracing::warn!("brain harvest failed for session {session_id}: {e}");
298            }
299        });
300
301        tokio::spawn(async move {
302            if let Err(join_err) = handle.await {
303                if join_err.is_panic() {
304                    let payload = join_err.into_panic();
305                    tracing::warn!(
306                        "brain harvest task panicked for session {panic_session_id}: {}",
307                        panic_message(payload.as_ref()),
308                    );
309                } else {
310                    tracing::warn!("brain harvest task for session {panic_session_id} was cancelled");
311                }
312            }
313        });
314    }
315
316    /// Forward every pending `ninox request-work` entry for this session to
317    /// the UI and the orchestrator, then move it out of the pending set.
318    async fn deliver_work_requests(&self, session: &crate::types::Session, sessions_dir: &std::path::Path) {
319        let pending = match hooks::read_pending_work_requests(sessions_dir, &session.id) {
320            Ok(p) if !p.is_empty() => p,
321            _ => return,
322        };
323        for request in &pending {
324            self.engine.emit(Event::Notification(Notification {
325                id:         format!("work-request-{}", request.id),
326                kind:       NotificationKind::WorkRequested,
327                title:      format!("Work requested — {}", session.name),
328                body:       request.description.clone(),
329                session_id: Some(session.id.clone()),
330                created_at: now_millis(),
331            }));
332            if let Some(orch) = session.orchestrator_id.clone() {
333                let msg = crate::lifecycle::reactions::format_work_request_reaction(
334                    session, &request.description,
335                );
336                if let Err(e) = self.engine.send_to_session(&orch, &msg).await {
337                    tracing::warn!("send work request to orchestrator {orch}: {e}");
338                }
339            }
340        }
341        // Marked delivered even when the tmux nudge failed — the UI
342        // notification is already out, and retrying every tick would spam
343        // both channels.
344        let ids: Vec<String> = pending.iter().map(|r| r.id.clone()).collect();
345        if let Err(e) = hooks::mark_work_requests_delivered(sessions_dir, &session.id, &ids) {
346            tracing::warn!("mark work requests delivered for {}: {e}", session.id);
347        }
348    }
349
350    // ── Cost / context-window usage ─────────────────────────────────────────
351
352    /// Ingest cost/token usage for every active session by reading `claude`'s
353    /// own transcript for the session's workspace directory (see
354    /// `lifecycle::usage`). Sessions without a workspace, or whose transcript
355    /// has no usage yet (agent hasn't taken a turn), are left untouched.
356    /// Only writes + emits when something actually changed, so this doesn't
357    /// spam the store/UI every tick for idle sessions.
358    async fn poll_usage(&self) {
359        let Ok(sessions) = self.engine.store.list_sessions() else { return };
360        for mut session in sessions {
361            if matches!(session.status, SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted) {
362                continue;
363            }
364            let Some(workspace) = session.workspace_path.clone() else { continue };
365            let Some(snapshot) = usage::ingest_usage_for_workspace(&workspace) else { continue };
366
367            let cost_changed = (session.cost_usd - snapshot.cost_usd).abs() > 1e-9;
368            let context_changed = session.context_tokens != Some(snapshot.context_tokens);
369            if !cost_changed && !context_changed {
370                continue;
371            }
372
373            session.cost_usd = snapshot.cost_usd;
374            session.context_tokens = Some(snapshot.context_tokens);
375            if session.model.is_none() {
376                session.model = snapshot.model;
377            }
378            let _ = self.engine.store.upsert_session(&session);
379            self.engine.emit(Event::SessionUpdated(session));
380        }
381    }
382
383    // ── Statusline-sourced cost/context updates (external writer) ──────────
384
385    /// The `ninox statusline` subcommand (invoked by Claude Code's own
386    /// `statusLine` hook — see `lifecycle::statusline`) writes cost/context
387    /// fields directly into the store from a separate short-lived process.
388    /// Unlike every other poll method, this data doesn't arrive via a
389    /// read-modify-write cycle this poller drives, so there's nothing to
390    /// diff against except a cache of the last-seen values. Detects
391    /// external changes and re-broadcasts them as `SessionUpdated` so the
392    /// GUI picks them up.
393    async fn poll_context_updates(&self) {
394        let Ok(sessions) = self.engine.store.list_sessions() else { return };
395        let mut changed = Vec::new();
396        {
397            let mut cache = self.context_cache.lock().unwrap();
398            for session in sessions {
399                let key = (session.cost_usd, session.context_used_pct, session.context_total_tokens);
400                // `None` means this session has never been cached — seed it
401                // silently rather than treating "no prior state" as a change
402                // (that would spam an event for every session on startup).
403                if let Some(prev) = cache.insert(session.id.clone(), key) {
404                    if prev != key {
405                        changed.push(session);
406                    }
407                }
408            }
409        }
410        for session in changed {
411            self.engine.emit(Event::SessionUpdated(session));
412        }
413    }
414
415    // ── GitHub enrichment ────────────────────────────────────────────────────
416
417    async fn poll_github(&self) {
418        let Some(gh) = &self.engine.github else { return };
419        let Ok(sessions) = self.engine.store.list_sessions() else { return };
420
421        for mut session in sessions {
422            // Only `Done` is excluded here — a session already has its PR's
423            // merge handled by definition once `Done`. `Terminated` (the
424            // worker's own process exited, typically once its PR is merely
425            // *open*) and `Interrupted` sessions may still have a PR whose
426            // fate hasn't resolved yet, so they must keep being polled or a
427            // later merge becomes permanently invisible (no status update,
428            // no notification) the instant the process dies.
429            if matches!(session.status, SessionStatus::Done) {
430                continue;
431            }
432            let Some(pr_number) = session.pr_number else { continue };
433
434            // -- PR state — try the repo on record first (the common case,
435            // no extra requests, and the only case where reusing the same
436            // numeric `pr_number` is valid: it was recorded *for that repo
437            // specifically*). If that 404s, DO NOT retry the same
438            // `pr_number` against another remote's repo — PR numbers are a
439            // per-repository sequence with no cross-repo relationship, so a
440            // different repo (e.g. an internal mirror) can easily have some
441            // unrelated PR at that same number. Instead, match on the
442            // session's actual branch, the same way `poll_pr_reconciliation`
443            // does, and adopt whatever PR number that repo's branch match
444            // actually has. --
445            let mut found: Option<(String, u64, crate::github::PrStatus)> = None;
446            let mut last_err: Option<anyhow::Error> = None;
447            let mut attempted = false;
448            if !session.repo.is_empty() {
449                if let Some((owner, repo)) = split_repo(&session.repo) {
450                    attempted = true;
451                    match gh.get_pr_status(&owner, &repo, pr_number).await {
452                        Ok(s)  => found = Some((session.repo.clone(), pr_number, s)),
453                        Err(e) => last_err = Some(e),
454                    }
455                }
456            }
457            if found.is_none() {
458                if let Some(workspace) = session.workspace_path.clone() {
459                    if let Some(branch) = crate::github::current_branch(&workspace) {
460                        for repo_slug in crate::github::candidate_repos(&workspace) {
461                            if repo_slug == session.repo {
462                                continue; // already tried above
463                            }
464                            let Some((owner, repo)) = split_repo(&repo_slug) else { continue };
465                            attempted = true;
466                            let pr_ref = match gh.find_open_pr_for_branch(&owner, &repo, &branch).await {
467                                Ok(Some(r)) => r,
468                                Ok(None)    => continue,
469                                Err(e)      => { last_err = Some(e); continue; }
470                            };
471                            match gh.get_pr_status(&owner, &repo, pr_ref.number).await {
472                                Ok(s)  => { found = Some((repo_slug, pr_ref.number, s)); break; }
473                                Err(e) => { last_err = Some(e); continue; }
474                            }
475                        }
476                    }
477                }
478            }
479            if !attempted {
480                continue; // nothing parseable to check — same as pre-fallback behavior
481            }
482            let Some((resolved_repo, pr_number, pr_status)) = found else {
483                if let Some(e) = last_err {
484                    tracing::warn!("github pr status for {}: {e}", session.id);
485                }
486                self.notify_github_lookup_failed(&session);
487                continue;
488            };
489            self.clear_github_lookup_failed(&session.id);
490
491            // Self-heal: the PR was found against a different remote (and
492            // possibly a different PR number in that remote's own sequence)
493            // than the one on record — persist it so future ticks go
494            // straight there instead of re-discovering it via fallback
495            // every time.
496            if resolved_repo != session.repo || Some(pr_number) != session.pr_number {
497                tracing::info!(
498                    "session {} repo/PR corrected {}#{:?} -> {resolved_repo}#{pr_number}",
499                    session.id, session.repo, session.pr_number,
500                );
501                session.repo = resolved_repo;
502                session.pr_number = Some(pr_number);
503                let _ = self.engine.store.upsert_session(&session);
504            }
505            let Some((owner, repo)) = split_repo(&session.repo) else { continue };
506
507            let pr_id: PrId = pr_number as i64;
508
509            // -- Merge detection — handle before CI (no point polling CI on merged PR) --
510            if self.handle_merge_detection(&session, pr_number, pr_status.merged).await {
511                continue; // skip further enrichment for this session
512            }
513
514            // Upsert PR record — only when not merged (merged sessions stay Done after cleanup)
515            {
516                let pr = PR {
517                    id:         pr_id,
518                    number:     pr_number,
519                    title:      pr_status.title.clone(),
520                    url:        format!("https://github.com/{owner}/{repo}/pull/{pr_number}"),
521                    body:       String::new(),
522                    session_id: session.id.clone(),
523                };
524                let _ = self.engine.store.upsert_pr(&pr);
525                self.engine.emit(Event::PrOpened { session_id: session.id.clone(), pr });
526            }
527
528            // -- CI checks --
529            let checks = match gh.get_ci_checks(&owner, &repo, &pr_status.head_sha).await {
530                Ok(c)  => c,
531                Err(e) => { tracing::warn!("github ci checks: {e}"); vec![] }
532            };
533            let ci = summarize_checks(pr_id, &checks);
534            let _ = self.engine.store.upsert_ci_status(&ci);
535            self.engine.emit(Event::CiUpdated { pr_id, status: ci.clone() });
536
537            // -- Detect CI transition and update session status --
538            let (newly_failing, ci_reaction_already_sent) = {
539                let mut cache = self.enrichment_cache.lock().unwrap();
540                let state = cache.entry(session.id.clone()).or_default();
541
542                let newly_failing = state.prev_failing.is_none_or(|p| p == 0)
543                    && ci.failing > 0;
544                state.prev_failing = Some(ci.failing);
545
546                let already_sent = state.ci_reaction_sent;
547                if newly_failing && !already_sent {
548                    state.ci_reaction_sent = true;
549                }
550                if ci.failing == 0 {
551                    state.ci_reaction_sent = false;
552                }
553                (newly_failing, already_sent)
554            };
555
556            if newly_failing && !ci_reaction_already_sent {
557                self.engine.emit(Event::Notification(Notification {
558                    id:         format!("ci-{}", session.id),
559                    kind:       NotificationKind::CiFailure,
560                    title:      format!("CI failing — {}", session.name),
561                    body:       format!("{}/{} checks failing", ci.failing, ci.total),
562                    session_id: Some(session.id.clone()),
563                    created_at: now_millis(),
564                }));
565                // Send reaction to the agent in the tmux session
566                let failing_names: Vec<String> = checks.iter()
567                    .filter(|c| c.conclusion.as_deref() == Some("failure")
568                             || c.conclusion.as_deref() == Some("timed_out"))
569                    .map(|c| c.name.clone())
570                    .collect();
571                let msg = crate::lifecycle::reactions::format_ci_reaction(
572                    &session, &ci, &failing_names
573                );
574                if let Err(e) = self.engine.send_to_session(&session.id, &msg).await {
575                    tracing::warn!("send ci reaction to {}: {e}", session.id);
576                }
577            }
578
579            // -- Review threads (throttled via seen_comment_ids) --
580            let threads = match gh.get_review_threads(&owner, &repo, pr_number).await {
581                Ok(t)  => t,
582                Err(e) => { tracing::warn!("github review threads: {e}"); vec![] }
583            };
584
585            let has_changes_requested = threads.iter().any(|t| t.state == "CHANGES_REQUESTED");
586
587            let (has_new, review_reaction_already_sent, new_comments) = {
588                let mut cache = self.enrichment_cache.lock().unwrap();
589                let state = cache.entry(session.id.clone()).or_default();
590                let mut has_new = false;
591                let mut new_comments: Vec<Comment> = Vec::new();
592
593                for thread in &threads {
594                    if thread.state == "CHANGES_REQUESTED"
595                        && !state.seen_comment_ids.contains(&thread.id)
596                    {
597                        state.seen_comment_ids.insert(thread.id);
598                        has_new = true;
599                        let comment = Comment {
600                            id:         thread.id,
601                            pr_id,
602                            author:     thread.author.clone(),
603                            body:       thread.body.clone(),
604                            path:       thread.path.clone(),
605                            line:       thread.line,
606                            created_at: 0,
607                        };
608                        let _ = self.engine.store.upsert_comment(&comment);
609                        self.engine.emit(Event::ReviewComment { pr_id, comment: comment.clone() });
610                        new_comments.push(comment);
611                    }
612                }
613
614                let already_sent = state.review_reaction_sent;
615                if has_new && !already_sent {
616                    state.review_reaction_sent = true;
617                }
618                // Reset when all CHANGES_REQUESTED are resolved
619                if !has_changes_requested {
620                    state.review_reaction_sent = false;
621                }
622                (has_new, already_sent, new_comments)
623            };
624
625            // Update session status in DB (after review threads so has_changes_requested is known)
626            let new_status = derive_session_status(&session.status, &pr_status, &ci, has_changes_requested);
627            let mut updated = session.clone();
628            updated.status = new_status;
629            if updated.status != session.status {
630                let _ = self.engine.store.upsert_session(&updated);
631                self.engine.emit(Event::SessionUpdated(updated.clone()));
632            }
633
634            if has_new && !review_reaction_already_sent {
635                self.engine.emit(Event::Notification(Notification {
636                    id:         format!("review-{}", session.id),
637                    kind:       NotificationKind::PrNeedsAttention,
638                    title:      format!("Review comments — {}", session.name),
639                    body:       "Changes requested on your PR".to_string(),
640                    session_id: Some(session.id.clone()),
641                    created_at: now_millis(),
642                }));
643                if !new_comments.is_empty() {
644                    let msg = crate::lifecycle::reactions::format_review_reaction(
645                        &session, &new_comments
646                    );
647                    if let Err(e) = self.engine.send_to_session(&session.id, &msg).await {
648                        tracing::warn!("send review reaction to {}: {e}", session.id);
649                    }
650                }
651            }
652        }
653    }
654
655    /// Emit a `GithubLookupFailed` notification once per run of consecutive
656    /// failures — deduped the same way `ci_reaction_sent` dedupes CI-failure
657    /// reactions, via the enrichment cache.
658    fn notify_github_lookup_failed(&self, session: &crate::types::Session) {
659        let already_notified = {
660            let mut cache = self.enrichment_cache.lock().unwrap();
661            let state = cache.entry(session.id.clone()).or_default();
662            let already = state.github_lookup_failed_notified;
663            state.github_lookup_failed_notified = true;
664            already
665        };
666        if already_notified {
667            return;
668        }
669        self.engine.emit(Event::Notification(Notification {
670            id:         format!("github-lookup-failed-{}", session.id),
671            kind:       NotificationKind::GithubLookupFailed,
672            title:      format!("GitHub lookup failing — {}", session.name),
673            body:       "PR status/CI/review polling failed against every configured remote"
674                .to_string(),
675            session_id: Some(session.id.clone()),
676            created_at: now_millis(),
677        }));
678    }
679
680    /// Clear the dedup flag once a lookup succeeds again — the next failure
681    /// (if any) gets its own fresh notification.
682    fn clear_github_lookup_failed(&self, session_id: &str) {
683        let mut cache = self.enrichment_cache.lock().unwrap();
684        if let Some(state) = cache.get_mut(session_id) {
685            state.github_lookup_failed_notified = false;
686        }
687    }
688
689    // ── PR reconciliation (active fallback) ─────────────────────────────────
690
691    /// For every non-terminal session that has no tracked PR yet, actively
692    /// check whether a PR already exists for its branch — independent of
693    /// whether the wrapped `gh pr create` ever ran (a manual `git push` +
694    /// PR opened via the GitHub web UI, `hub`, a shell alias, or the wrapper
695    /// simply missing an unrecognized `gh` invocation shape all leave
696    /// `pr_number` at `None` forever without this). Tries every configured
697    /// remote, not just `origin`, for the same dual-remote reason as
698    /// `poll_github`'s fallback.
699    async fn poll_pr_reconciliation(&self) {
700        let Some(gh) = &self.engine.github else { return };
701        let Ok(sessions) = self.engine.store.list_sessions() else { return };
702
703        for mut session in sessions {
704            if session.pr_number.is_some() {
705                continue;
706            }
707            if matches!(session.status, SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted) {
708                continue;
709            }
710            let Some(workspace) = session.workspace_path.clone() else { continue };
711            let Some(branch) = crate::github::current_branch(&workspace) else { continue };
712
713            for repo_slug in crate::github::candidate_repos(&workspace) {
714                let Some((owner, repo)) = split_repo(&repo_slug) else { continue };
715                match gh.find_open_pr_for_branch(&owner, &repo, &branch).await {
716                    Ok(Some(pr_ref)) => {
717                        session.pr_number = Some(pr_ref.number);
718                        session.repo      = repo_slug.clone();
719                        session.status    = SessionStatus::PrOpen;
720                        let _ = self.engine.store.upsert_session(&session);
721                        self.engine.emit(Event::SessionUpdated(session.clone()));
722                        tracing::info!(
723                            "session {} PR #{} detected via reconciliation ({repo_slug}, branch {branch})",
724                            session.id, pr_ref.number,
725                        );
726                        break;
727                    }
728                    Ok(None) => continue,
729                    Err(e) => {
730                        tracing::warn!("reconciliation lookup {repo_slug} branch {branch}: {e}");
731                        continue;
732                    }
733                }
734            }
735        }
736    }
737    /// Handle a freshly-fetched PR status's merge state for `session`.
738    /// Returns `true` when the merge was handled this call (the caller
739    /// should skip further CI/review enrichment for the session this tick);
740    /// `false` when there's nothing to do (not merged, or already `Done` —
741    /// reusing the status transition itself as the one-shot dedup guard, so
742    /// a session can never fire this reaction twice).
743    ///
744    /// Extracted from `poll_github` so it's directly unit-testable without a
745    /// live GitHub client — `poll_github` only needs the client to fetch
746    /// `pr_status` in the first place; this handles the resulting state
747    /// unconditionally.
748    async fn handle_merge_detection(
749        &self,
750        session:    &Session,
751        pr_number:  u64,
752        pr_merged:  bool,
753    ) -> bool {
754        if !pr_merged || matches!(session.status, SessionStatus::Done) {
755            return false;
756        }
757        self.engine.emit(Event::Notification(Notification {
758            id:         format!("merged-{}", session.id),
759            kind:       NotificationKind::WorkerDone,
760            title:      format!("PR merged — {}", session.name),
761            body:       format!("#{} merged successfully", pr_number),
762            session_id: Some(session.id.clone()),
763            created_at: now_millis(),
764        }));
765        // Code-level completion guarantee for the orchestrator — independent
766        // of whether the worker's own agent ever reports back before exiting.
767        if let Some(orch) = session.orchestrator_id.clone() {
768            let msg = crate::lifecycle::reactions::format_worker_done_reaction(session, pr_number);
769            if let Err(e) = self.engine.send_to_session(&orch, &msg).await {
770                tracing::warn!("send worker-done reaction to orchestrator {orch}: {e}");
771            }
772        }
773        if let Err(e) = self.engine.cleanup_session(&session.id).await {
774            tracing::warn!("cleanup_session {}: {e}", session.id);
775        }
776        // Remove enrichment state for this session — it's done
777        {
778            let mut cache = self.enrichment_cache.lock().unwrap();
779            cache.remove(&session.id);
780        }
781        true
782    }
783
784    // ── Retention sweep ──────────────────────────────────────────────────────
785
786    /// Purge `Done`/`Terminated` session records whose terminal state was
787    /// reached more than `retention`'s window ago — gives the fleet board a
788    /// grace period to show what just completed instead of the record
789    /// vanishing the instant the poller marks it done (see
790    /// `Session::terminal_at`). Sessions with no `terminal_at` (a terminal
791    /// state reached via a direct user action —
792    /// `Engine::terminate_session`/`Engine::remove_session` — rather than
793    /// this automatic lifecycle path) have no grace period and are purged
794    /// on sight, preserving today's immediate disappearance for those
795    /// actions. Orchestrator sessions are never purged this way.
796    async fn sweep_retired_sessions(&self, retention: &SessionRetentionConfig) {
797        let Ok(sessions) = self.engine.store.list_sessions() else { return };
798        let Ok(orchestrators) = self.engine.store.list_orchestrators() else { return };
799        let orch_ids: std::collections::HashSet<&str> =
800            orchestrators.iter().map(|o| o.id.as_str()).collect();
801
802        let now = now_millis();
803        let retention_ms = retention.retention_millis();
804
805        for session in sessions {
806            if !matches!(session.status, SessionStatus::Done | SessionStatus::Terminated) {
807                continue;
808            }
809            if orch_ids.contains(session.id.as_str()) {
810                continue;
811            }
812            let expired = match session.terminal_at {
813                Some(t) => now.saturating_sub(t) >= retention_ms,
814                None    => true,
815            };
816            if !expired {
817                continue;
818            }
819            if let Err(e) = self.engine.store.delete_session(&session.id) {
820                tracing::warn!("purge retired session {}: {e}", session.id);
821                continue;
822            }
823            self.engine.emit(Event::SessionDone(session.id));
824        }
825    }
826}
827
828// ── Helpers ──────────────────────────────────────────────────────────────────
829
830fn summarize_checks(pr_id: PrId, checks: &[CheckRun]) -> CIStatus {
831    let total   = checks.len() as u32;
832    let failing = checks.iter().filter(|c| {
833        c.conclusion.as_deref() == Some("failure")
834            || c.conclusion.as_deref() == Some("timed_out")
835    }).count() as u32;
836    let passing = checks.iter().filter(|c| {
837        c.conclusion.as_deref() == Some("success")
838    }).count() as u32;
839    let pending = total - failing - passing;
840    CIStatus { pr_id, total, failing, passing, pending }
841}
842
843fn derive_session_status(
844    current:               &SessionStatus,
845    pr_status:             &crate::github::PrStatus,
846    ci:                    &CIStatus,
847    has_changes_requested: bool,
848) -> SessionStatus {
849    // Terminal states are never overwritten.
850    if matches!(current, SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted) {
851        return current.clone();
852    }
853    if pr_status.merged {
854        return SessionStatus::Done;
855    }
856    if ci.failing > 0 {
857        return SessionStatus::CiFailed;
858    }
859    if has_changes_requested {
860        return SessionStatus::ReviewPending;
861    }
862    if pr_status.mergeable == Some(true) && ci.failing == 0 && ci.pending == 0 {
863        return SessionStatus::Mergeable;
864    }
865    SessionStatus::PrOpen
866}
867
868#[cfg(test)]
869mod tests {
870    use super::*;
871    use crate::types::SessionStatus;
872
873    #[test]
874    fn summarize_checks_counts_failures() {
875        let checks = vec![
876            CheckRun { name: "lint".into(), status: "completed".into(), conclusion: Some("success".into()) },
877            CheckRun { name: "test".into(), status: "completed".into(), conclusion: Some("failure".into()) },
878            CheckRun { name: "build".into(), status: "in_progress".into(), conclusion: None },
879        ];
880        let ci = summarize_checks(1, &checks);
881        assert_eq!(ci.total,   3);
882        assert_eq!(ci.passing, 1);
883        assert_eq!(ci.failing, 1);
884        assert_eq!(ci.pending, 1);
885    }
886
887    #[test]
888    fn derive_status_merged_becomes_done() {
889        let pr = crate::github::PrStatus {
890            merged: true, state: "closed".into(), mergeable: None,
891            title: "t".into(), number: 1, head_sha: String::new(),
892        };
893        let ci = CIStatus { pr_id: 1, total: 0, failing: 0, passing: 0, pending: 0 };
894        let s  = derive_session_status(&SessionStatus::PrOpen, &pr, &ci, false);
895        assert!(matches!(s, SessionStatus::Done));
896    }
897
898    #[test]
899    fn derive_status_ci_failure_overrides_open() {
900        let pr = crate::github::PrStatus {
901            merged: false, state: "open".into(), mergeable: Some(true),
902            title: "t".into(), number: 1, head_sha: String::new(),
903        };
904        let ci = CIStatus { pr_id: 1, total: 3, failing: 1, passing: 2, pending: 0 };
905        let s  = derive_session_status(&SessionStatus::PrOpen, &pr, &ci, false);
906        assert!(matches!(s, SessionStatus::CiFailed));
907    }
908
909    #[test]
910    fn derive_status_all_green_becomes_mergeable() {
911        let pr = crate::github::PrStatus {
912            merged: false, state: "open".into(), mergeable: Some(true),
913            title: "t".into(), number: 1, head_sha: String::new(),
914        };
915        let ci = CIStatus { pr_id: 1, total: 3, failing: 0, passing: 3, pending: 0 };
916        let s  = derive_session_status(&SessionStatus::PrOpen, &pr, &ci, false);
917        assert!(matches!(s, SessionStatus::Mergeable));
918    }
919
920    #[test]
921    fn derive_status_preserves_done() {
922        let pr = crate::github::PrStatus {
923            merged: false, state: "open".into(), mergeable: Some(true),
924            title: "t".into(), number: 1, head_sha: String::new(),
925        };
926        let ci = CIStatus { pr_id: 1, total: 0, failing: 0, passing: 0, pending: 0 };
927        let s  = derive_session_status(&SessionStatus::Done, &pr, &ci, false);
928        assert!(matches!(s, SessionStatus::Done));
929    }
930
931    #[test]
932    fn derive_status_preserves_terminated() {
933        let pr = crate::github::PrStatus {
934            merged: true, state: "closed".into(), mergeable: None,   // merged=true!
935            title: "t".into(), number: 1, head_sha: String::new(),
936        };
937        let ci = CIStatus { pr_id: 1, total: 0, failing: 0, passing: 0, pending: 0 };
938        let s  = derive_session_status(&SessionStatus::Terminated, &pr, &ci, false);
939        assert!(matches!(s, SessionStatus::Terminated));  // must not become Done
940    }
941
942    #[test]
943    fn derive_status_changes_requested_becomes_review_pending() {
944        let pr = crate::github::PrStatus {
945            merged: false, state: "open".into(), mergeable: Some(true),
946            title: "t".into(), number: 1, head_sha: String::new(),
947        };
948        let ci = CIStatus { pr_id: 1, total: 3, failing: 0, passing: 3, pending: 0 };
949        let s  = derive_session_status(&SessionStatus::PrOpen, &pr, &ci, true);
950        assert!(matches!(s, SessionStatus::ReviewPending));
951    }
952
953    fn test_session(id: &str, workspace: &str) -> crate::types::Session {
954        crate::types::Session {
955            id: id.into(), orchestrator_id: None, name: id.into(),
956            repo: String::new(), status: SessionStatus::Working,
957            agent_type: "claude-code".into(), cost_usd: 0.0, started_at: 0,
958            pr_number: None, pr_id: None,
959            workspace_path: Some(workspace.into()), pid: None,
960            model: None, context_tokens: None, catalogue_path: None,
961            context_used_pct: None, context_total_tokens: None, context_window_size: None,
962            claude_session_id: None,
963            terminal_at: None,
964        }
965    }
966
967    #[tokio::test]
968    async fn poll_pids_leaves_interrupted_sessions_alone() {
969        use crate::store::Store;
970
971        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
972        let mut s = test_session("interrupted-1", "/ws");
973        s.status = SessionStatus::Interrupted;
974        s.pid = Some(999_999); // a pid that is almost certainly dead
975        store.upsert_session(&s).unwrap();
976        let engine = Engine::new(store.clone());
977        let poller = Poller::new(engine);
978
979        poller.poll_pids().await;
980
981        let after = store.get_session("interrupted-1").unwrap().unwrap();
982        assert!(
983            matches!(after.status, SessionStatus::Interrupted),
984            "poll_pids must not re-terminate an Interrupted session just because its stale pid is dead",
985        );
986    }
987
988    /// End-to-end (within-process) proof that the poller closes the gap
989    /// documented in `lifecycle::usage`: given a workspace whose `claude`
990    /// transcript directory has usage recorded, `poll_usage` writes the
991    /// derived cost/context/model back into the store and emits
992    /// `SessionUpdated` — the exact path the UI's $0.0000 / missing-tokens
993    /// symptom traces back to when this ingestion doesn't happen.
994    // The `ENV_TEST_GUARD` mutex is intentionally held across the `.await`
995    // points below — it serializes access to the process-global
996    // `NINOX_CLAUDE_PROJECTS_DIR` env var against other tests (in this file
997    // and in `lifecycle::usage`) for this single-threaded `#[tokio::test]`,
998    // and must stay held for the env var's entire lifetime, not just around
999    // the sync portions.
1000    #[allow(clippy::await_holding_lock)]
1001    #[tokio::test]
1002    async fn poll_usage_ingests_transcript_into_store_and_emits_update() {
1003        use crate::{lifecycle::usage::{claude_project_slug, ENV_TEST_GUARD}, store::Store};
1004        use std::io::Write;
1005
1006        let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1007        let projects_dir = tempfile::tempdir().unwrap();
1008        let workspace = "/tmp/poller-usage-probe-workspace";
1009        let project_dir = projects_dir.path().join(claude_project_slug(workspace));
1010        std::fs::create_dir_all(&project_dir).unwrap();
1011        let mut f = std::fs::File::create(project_dir.join("s.jsonl")).unwrap();
1012        writeln!(
1013            f,
1014            r#"{{"type":"assistant","timestamp":"2026-07-05T13:00:00.000Z","message":{{"model":"claude-fable-5","usage":{{"input_tokens":2,"output_tokens":300,"cache_creation_input_tokens":500,"cache_read_input_tokens":45000}}}}}}"#
1015        ).unwrap();
1016        drop(f);
1017
1018        let prior = std::env::var("NINOX_CLAUDE_PROJECTS_DIR").ok();
1019        std::env::set_var("NINOX_CLAUDE_PROJECTS_DIR", projects_dir.path());
1020
1021        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1022        store.upsert_session(&test_session("s1", workspace)).unwrap();
1023        let engine = Engine::new(store.clone());
1024        let mut rx = engine.subscribe();
1025        let poller = Poller::new(engine);
1026
1027        poller.poll_usage().await;
1028
1029        match prior {
1030            Some(v) => std::env::set_var("NINOX_CLAUDE_PROJECTS_DIR", v),
1031            None    => std::env::remove_var("NINOX_CLAUDE_PROJECTS_DIR"),
1032        }
1033
1034        let updated = store.get_session("s1").unwrap().unwrap();
1035        assert!(updated.cost_usd > 0.0, "cost_usd should be ingested, not 0.0000");
1036        assert_eq!(updated.context_tokens, Some(2 + 500 + 45000));
1037        assert_eq!(updated.model.as_deref(), Some("claude-fable-5"));
1038
1039        let evt = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
1040            .await
1041            .expect("SessionUpdated should be emitted")
1042            .unwrap();
1043        assert!(matches!(evt, Event::SessionUpdated(s) if s.id == "s1" && s.cost_usd > 0.0));
1044    }
1045
1046    /// The `ninox statusline` subcommand (a separate short-lived process)
1047    /// writes cost/context fields directly into the store — outside any
1048    /// read-modify-write cycle this poller drives. This proves the diff
1049    /// cache detects that external write and re-broadcasts it, and that an
1050    /// untouched session generates no spurious event.
1051    #[tokio::test]
1052    async fn poll_context_updates_emits_only_for_changed_sessions() {
1053        use crate::store::Store;
1054
1055        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1056        let mut s1 = test_session("s1", "/ws1");
1057        let s2 = test_session("s2", "/ws2");
1058        store.upsert_session(&s1).unwrap();
1059        store.upsert_session(&s2).unwrap();
1060        let engine = Engine::new(store.clone());
1061        let mut rx = engine.subscribe();
1062        let poller = Poller::new(engine);
1063
1064        // First tick establishes the baseline — nothing to diff against yet,
1065        // so it must not emit for sessions that already exist with no prior
1066        // cached state.
1067        poller.poll_context_updates().await;
1068        let baseline_events = drain_events(&mut rx);
1069        assert!(baseline_events.is_empty(), "no prior cached state means no change to report");
1070
1071        // Simulate the statusline hook writing directly into the store for s1 only.
1072        s1.context_used_pct = Some(42.0);
1073        s1.cost_usd = 3.5;
1074        store.upsert_session(&s1).unwrap();
1075
1076        poller.poll_context_updates().await;
1077        let events = drain_events(&mut rx);
1078        assert_eq!(events.len(), 1, "only the changed session should emit");
1079        assert!(matches!(
1080            &events[0],
1081            Event::SessionUpdated(s) if s.id == "s1" && s.context_used_pct == Some(42.0) && s.cost_usd == 3.5
1082        ));
1083
1084        // A third tick with no further changes emits nothing.
1085        poller.poll_context_updates().await;
1086        assert!(drain_events(&mut rx).is_empty());
1087    }
1088
1089    /// Drain every event currently buffered on the receiver.
1090    fn drain_events(rx: &mut tokio::sync::broadcast::Receiver<Event>) -> Vec<Event> {
1091        let mut events = Vec::new();
1092        while let Ok(e) = rx.try_recv() {
1093            events.push(e);
1094        }
1095        events
1096    }
1097
1098    /// A worker that opened three PRs: the first becomes the session's
1099    /// tracked PR, every later one is recorded in the store and raised as an
1100    /// ExtraPr notification — and only once, however often the poller ticks.
1101    #[tokio::test]
1102    async fn metadata_sync_adopts_first_pr_and_flags_every_extra_once() {
1103        use crate::store::Store;
1104
1105        let sessions_dir = tempfile::tempdir().unwrap();
1106        let meta = serde_json::json!({
1107            "agentReportedPrNumber": "44",
1108            "agentReportedPrUrl": "https://github.com/org/repo/pull/44",
1109            "agentReportedPrs": [
1110                {"number": "42", "url": "https://github.com/org/repo/pull/42"},
1111                {"number": "43", "url": "https://github.com/org/repo/pull/43"},
1112                {"number": "44", "url": "https://github.com/org/repo/pull/44"},
1113            ],
1114        });
1115        std::fs::write(
1116            sessions_dir.path().join("s1.json"),
1117            serde_json::to_string(&meta).unwrap(),
1118        ).unwrap();
1119
1120        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1121        store.upsert_session(&test_session("s1", "/ws")).unwrap();
1122        let engine = Engine::new(store.clone());
1123        let mut rx = engine.subscribe();
1124        let poller = Poller::new(engine);
1125
1126        poller.sync_sessions_metadata(sessions_dir.path()).await;
1127
1128        let session = store.get_session("s1").unwrap().unwrap();
1129        assert_eq!(session.pr_number, Some(42), "first PR is the canonical one");
1130        assert!(matches!(session.status, SessionStatus::PrOpen));
1131        assert!(store.get_pr(43).unwrap().is_some(), "extra PR #43 recorded");
1132        assert!(store.get_pr(44).unwrap().is_some(), "extra PR #44 recorded");
1133        assert_eq!(
1134            store.get_pr(43).unwrap().unwrap().url,
1135            "https://github.com/org/repo/pull/43",
1136        );
1137
1138        let events = drain_events(&mut rx);
1139        let extra_notifs: Vec<_> = events.iter().filter(|e| matches!(
1140            e, Event::Notification(n) if n.kind == crate::types::NotificationKind::ExtraPr
1141        )).collect();
1142        assert_eq!(extra_notifs.len(), 2, "one ExtraPr notification per extra PR");
1143
1144        // Second tick: nothing new — no duplicate notifications.
1145        poller.sync_sessions_metadata(sessions_dir.path()).await;
1146        let events = drain_events(&mut rx);
1147        assert!(
1148            !events.iter().any(|e| matches!(e, Event::Notification(_))),
1149            "extra PRs must not be re-notified on every tick",
1150        );
1151    }
1152
1153    /// A single reported PR (the normal case) adopts it with no extra-PR
1154    /// noise — the pre-existing first-PR-detection behavior.
1155    #[tokio::test]
1156    async fn metadata_sync_single_pr_has_no_extra_notifications() {
1157        use crate::store::Store;
1158
1159        let sessions_dir = tempfile::tempdir().unwrap();
1160        let meta = serde_json::json!({
1161            "agentReportedPrNumber": "5",
1162            "agentReportedPrUrl": "https://github.com/org/repo/pull/5",
1163        });
1164        std::fs::write(
1165            sessions_dir.path().join("s1.json"),
1166            serde_json::to_string(&meta).unwrap(),
1167        ).unwrap();
1168
1169        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1170        store.upsert_session(&test_session("s1", "/ws")).unwrap();
1171        let engine = Engine::new(store.clone());
1172        let mut rx = engine.subscribe();
1173        let poller = Poller::new(engine);
1174
1175        poller.sync_sessions_metadata(sessions_dir.path()).await;
1176
1177        let session = store.get_session("s1").unwrap().unwrap();
1178        assert_eq!(session.pr_number, Some(5));
1179        assert!(matches!(session.status, SessionStatus::PrOpen));
1180        let events = drain_events(&mut rx);
1181        assert!(!events.iter().any(|e| matches!(e, Event::Notification(_))));
1182    }
1183
1184    /// Work requests recorded by `ninox request-work` surface exactly one
1185    /// WorkRequested notification each, then are marked delivered.
1186    #[tokio::test]
1187    async fn metadata_sync_delivers_work_requests_exactly_once() {
1188        use crate::store::Store;
1189
1190        let sessions_dir = tempfile::tempdir().unwrap();
1191        hooks::append_work_request(sessions_dir.path(), "s1", "Migrate the config loader").unwrap();
1192
1193        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1194        store.upsert_session(&test_session("s1", "/ws")).unwrap();
1195        let engine = Engine::new(store.clone());
1196        let mut rx = engine.subscribe();
1197        let poller = Poller::new(engine);
1198
1199        poller.sync_sessions_metadata(sessions_dir.path()).await;
1200
1201        let events = drain_events(&mut rx);
1202        let notif = events.iter().find_map(|e| match e {
1203            Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkRequested => Some(n),
1204            _ => None,
1205        }).expect("WorkRequested notification emitted");
1206        assert!(notif.body.contains("Migrate the config loader"));
1207        assert_eq!(notif.session_id.as_deref(), Some("s1"));
1208
1209        assert!(
1210            hooks::read_pending_work_requests(sessions_dir.path(), "s1").unwrap().is_empty(),
1211            "delivered requests must leave the pending set",
1212        );
1213
1214        poller.sync_sessions_metadata(sessions_dir.path()).await;
1215        let events = drain_events(&mut rx);
1216        assert!(
1217            !events.iter().any(|e| matches!(e, Event::Notification(_))),
1218            "delivered work requests must not fire again",
1219        );
1220    }
1221
1222    /// A worker can request work and exit before the next tick — the request
1223    /// must still reach the orchestrator, not die with the session.
1224    #[tokio::test]
1225    async fn metadata_sync_delivers_work_requests_from_terminated_sessions() {
1226        use crate::store::Store;
1227
1228        let sessions_dir = tempfile::tempdir().unwrap();
1229        hooks::append_work_request(sessions_dir.path(), "s1", "Follow-up refactor").unwrap();
1230
1231        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1232        let mut session = test_session("s1", "/ws");
1233        session.status = SessionStatus::Terminated;
1234        store.upsert_session(&session).unwrap();
1235        let engine = Engine::new(store.clone());
1236        let mut rx = engine.subscribe();
1237        let poller = Poller::new(engine);
1238
1239        poller.sync_sessions_metadata(sessions_dir.path()).await;
1240
1241        let events = drain_events(&mut rx);
1242        assert!(
1243            events.iter().any(|e| matches!(
1244                e, Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkRequested
1245            )),
1246            "work requests outlive their session",
1247        );
1248    }
1249
1250    /// The ledger row for an extra PR is best-effort at notification time (a
1251    /// busy store must not kill the alert) — but it must self-heal on later
1252    /// ticks rather than be lost forever, and healing must not re-notify.
1253    #[tokio::test]
1254    async fn extra_pr_ledger_row_backfills_after_notification_without_renotifying() {
1255        use crate::store::Store;
1256
1257        let sessions_dir = tempfile::tempdir().unwrap();
1258        let meta = serde_json::json!({
1259            "agentReportedPrs": [
1260                {"number": "7", "url": "https://github.com/org/repo/pull/7"},
1261                {"number": "9", "url": "https://github.com/org/repo/pull/9"},
1262            ],
1263        });
1264        std::fs::write(
1265            sessions_dir.path().join("s1.json"),
1266            serde_json::to_string(&meta).unwrap(),
1267        ).unwrap();
1268        // Simulate "notified previously, but the row write failed that tick".
1269        hooks::mark_extra_prs_notified(sessions_dir.path(), "s1", &[9]).unwrap();
1270
1271        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1272        store.upsert_session(&test_session("s1", "/ws")).unwrap();
1273        let engine = Engine::new(store.clone());
1274        let mut rx = engine.subscribe();
1275        let poller = Poller::new(engine);
1276
1277        poller.sync_sessions_metadata(sessions_dir.path()).await;
1278
1279        assert!(
1280            store.get_pr(9).unwrap().is_some(),
1281            "already-notified extra PR must still get its ledger row backfilled",
1282        );
1283        let events = drain_events(&mut rx);
1284        assert!(
1285            !events.iter().any(|e| matches!(
1286                e, Event::Notification(n) if n.kind == crate::types::NotificationKind::ExtraPr
1287            )),
1288            "backfilling the row must not re-notify",
1289        );
1290    }
1291
1292    /// Extra-PR dedup must not be fooled by an unrelated session in another
1293    /// repo already owning the `prs` row for that number (prs.id is the bare
1294    /// PR number, which collides across repos) — and must not steal that row.
1295    #[tokio::test]
1296    async fn extra_pr_detection_survives_cross_repo_pr_number_collision() {
1297        use crate::store::Store;
1298
1299        let sessions_dir = tempfile::tempdir().unwrap();
1300        let meta = serde_json::json!({
1301            "agentReportedPrs": [
1302                {"number": "7", "url": "https://github.com/org/repo-a/pull/7"},
1303                {"number": "9", "url": "https://github.com/org/repo-a/pull/9"},
1304            ],
1305        });
1306        std::fs::write(
1307            sessions_dir.path().join("s1.json"),
1308            serde_json::to_string(&meta).unwrap(),
1309        ).unwrap();
1310
1311        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1312        store.upsert_session(&test_session("s1", "/ws")).unwrap();
1313        // Another repo's session already tracks its own PR #9.
1314        let other = PR {
1315            id: 9, number: 9, title: "other repo's PR".into(),
1316            url: "https://github.com/org/repo-b/pull/9".into(),
1317            body: String::new(), session_id: "other".into(),
1318        };
1319        store.upsert_pr(&other).unwrap();
1320
1321        let engine = Engine::new(store.clone());
1322        let mut rx = engine.subscribe();
1323        let poller = Poller::new(engine);
1324
1325        poller.sync_sessions_metadata(sessions_dir.path()).await;
1326
1327        let events = drain_events(&mut rx);
1328        assert!(
1329            events.iter().any(|e| matches!(
1330                e, Event::Notification(n) if n.kind == crate::types::NotificationKind::ExtraPr
1331            )),
1332            "the collision must not suppress the extra-PR alert",
1333        );
1334        let row = store.get_pr(9).unwrap().unwrap();
1335        assert_eq!(row.session_id, "other", "the other repo's row must not be stolen");
1336        assert_eq!(row.url, "https://github.com/org/repo-b/pull/9");
1337
1338        poller.sync_sessions_metadata(sessions_dir.path()).await;
1339        let events = drain_events(&mut rx);
1340        assert!(
1341            !events.iter().any(|e| matches!(e, Event::Notification(_))),
1342            "dedup must hold across ticks even without a prs row of our own",
1343        );
1344    }
1345
1346    #[tokio::test]
1347    async fn poll_usage_leaves_sessions_without_workspace_or_usage_untouched() {
1348        use crate::store::Store;
1349
1350        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1351        let mut no_ws = test_session("no-ws", "/does/not/matter");
1352        no_ws.workspace_path = None;
1353        store.upsert_session(&no_ws).unwrap();
1354        let engine = Engine::new(store.clone());
1355        let poller = Poller::new(engine);
1356
1357        poller.poll_usage().await;
1358
1359        let unchanged = store.get_session("no-ws").unwrap().unwrap();
1360        assert_eq!(unchanged.cost_usd, 0.0);
1361        assert_eq!(unchanged.context_tokens, None);
1362    }
1363
1364    // ── GithubApi fake — drives poll_github/poll_pr_reconciliation with no
1365    // network access, so the dual-remote fallback and reconciliation logic
1366    // can be exercised deterministically. ───────────────────────────────────
1367
1368    #[derive(Default)]
1369    struct FakeGithub {
1370        /// Keyed by (owner, repo, pr_number) — PR numbers are per-repo, so a
1371        /// fake that ignored the number couldn't catch a cross-repo number
1372        /// collision bug.
1373        pr_status_ok:   std::sync::Mutex<HashMap<(String, String, u64), crate::github::PrStatus>>,
1374        branch_matches: std::sync::Mutex<HashMap<(String, String, String), crate::github::PrRef>>,
1375        /// (owner, repo, pr_number) triples `get_pr_status` was actually called with, in order.
1376        calls: std::sync::Mutex<Vec<(String, String, u64)>>,
1377    }
1378
1379    #[async_trait::async_trait]
1380    impl crate::github::GithubApi for FakeGithub {
1381        async fn get_pr_status(&self, owner: &str, repo: &str, pr_number: u64) -> anyhow::Result<crate::github::PrStatus> {
1382            self.calls.lock().unwrap().push((owner.to_string(), repo.to_string(), pr_number));
1383            self.pr_status_ok.lock().unwrap()
1384                .get(&(owner.to_string(), repo.to_string(), pr_number))
1385                .cloned()
1386                .ok_or_else(|| anyhow::anyhow!("404 for {owner}/{repo}#{pr_number}"))
1387        }
1388        async fn get_ci_checks(&self, _owner: &str, _repo: &str, _head_sha: &str) -> anyhow::Result<Vec<CheckRun>> {
1389            Ok(vec![])
1390        }
1391        async fn get_review_threads(&self, _owner: &str, _repo: &str, _pr_number: u64) -> anyhow::Result<Vec<crate::github::ReviewThread>> {
1392            Ok(vec![])
1393        }
1394        async fn find_open_pr_for_branch(&self, owner: &str, repo: &str, branch: &str) -> anyhow::Result<Option<crate::github::PrRef>> {
1395            Ok(self.branch_matches.lock().unwrap()
1396                .get(&(owner.to_string(), repo.to_string(), branch.to_string()))
1397                .cloned())
1398        }
1399    }
1400
1401    fn init_git_repo(branch: &str, remotes: &[(&str, &str)]) -> tempfile::TempDir {
1402        let dir = tempfile::tempdir().unwrap();
1403        let workspace = dir.path().to_string_lossy().to_string();
1404        let run = |args: &[&str]| {
1405            let status = std::process::Command::new("git")
1406                .args(["-C", &workspace]).args(args).status().unwrap();
1407            assert!(status.success(), "git {args:?} failed");
1408        };
1409        run(&["init", "-q"]);
1410        run(&["config", "user.email", "test@example.com"]);
1411        run(&["config", "user.name", "Test"]);
1412        run(&["commit", "--allow-empty", "-q", "-m", "init"]);
1413        run(&["checkout", "-q", "-b", branch]);
1414        for (name, url) in remotes {
1415            run(&["remote", "add", name, url]);
1416        }
1417        dir
1418    }
1419
1420    fn github_engine(store: std::sync::Arc<crate::store::Store>, gh: std::sync::Arc<FakeGithub>) -> std::sync::Arc<Engine> {
1421        Engine::new_with_github_api(store, gh as std::sync::Arc<dyn crate::github::GithubApi>)
1422    }
1423
1424    /// The core acceptance criterion: a PR opened without the wrapped `gh pr
1425    /// create` ever running (no metadata file at all) must still end up
1426    /// adopted, purely from an active branch lookup against a configured
1427    /// remote.
1428    #[tokio::test]
1429    async fn poll_pr_reconciliation_adopts_pr_found_without_wrapper_metadata() {
1430        use crate::store::Store;
1431
1432        let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
1433        let workspace = repo_dir.path().to_string_lossy().to_string();
1434
1435        let fake = std::sync::Arc::new(FakeGithub::default());
1436        fake.branch_matches.lock().unwrap().insert(
1437            ("Owner".to_string(), "repo".to_string(), "worker-branch".to_string()),
1438            crate::github::PrRef { number: 77, url: "https://github.com/Owner/repo/pull/77".into() },
1439        );
1440
1441        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1442        store.upsert_session(&test_session("s1", &workspace)).unwrap();
1443        let engine = github_engine(store.clone(), fake);
1444        let poller = Poller::new(engine);
1445
1446        poller.poll_pr_reconciliation().await;
1447
1448        let session = store.get_session("s1").unwrap().unwrap();
1449        assert_eq!(session.pr_number, Some(77), "no wrapper metadata was ever written — reconciliation must still find it");
1450        assert!(matches!(session.status, SessionStatus::PrOpen));
1451        assert_eq!(session.repo, "Owner/repo");
1452    }
1453
1454    #[tokio::test]
1455    async fn poll_pr_reconciliation_leaves_sessions_with_no_matching_pr_untouched() {
1456        use crate::store::Store;
1457
1458        let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
1459        let workspace = repo_dir.path().to_string_lossy().to_string();
1460
1461        let fake = std::sync::Arc::new(FakeGithub::default()); // no branch matches configured
1462
1463        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1464        store.upsert_session(&test_session("s1", &workspace)).unwrap();
1465        let engine = github_engine(store.clone(), fake);
1466        let poller = Poller::new(engine);
1467
1468        poller.poll_pr_reconciliation().await;
1469
1470        let session = store.get_session("s1").unwrap().unwrap();
1471        assert_eq!(session.pr_number, None);
1472    }
1473
1474    /// The dual-remote gap from the bug report: the session's recorded repo
1475    /// (`origin`) 404s, but the PR actually lives against a second
1476    /// configured remote (an internal mirror) — `poll_github` must fall
1477    /// back to it instead of silently stalling, and self-heal `session.repo`
1478    /// (and `session.pr_number`, since PR numbers are per-repo) so later
1479    /// ticks go straight there. The mirror's real PR is deliberately given a
1480    /// *different* number (99, not the tracked 50) — a repo whose branch
1481    /// match is only found by matching the branch, not by coincidentally
1482    /// reusing the same numeric `pr_number`.
1483    #[tokio::test]
1484    async fn poll_github_falls_back_to_other_remote_when_recorded_repo_404s() {
1485        use crate::store::Store;
1486
1487        let repo_dir = init_git_repo("worker-branch", &[
1488            ("origin", "https://github.com/OwnerA/repoA.git"),
1489            ("mirror", "https://github.com/OwnerB/repoB.git"),
1490        ]);
1491        let workspace = repo_dir.path().to_string_lossy().to_string();
1492
1493        let fake = std::sync::Arc::new(FakeGithub::default());
1494        fake.branch_matches.lock().unwrap().insert(
1495            ("OwnerB".to_string(), "repoB".to_string(), "worker-branch".to_string()),
1496            crate::github::PrRef { number: 99, url: "https://github.com/OwnerB/repoB/pull/99".into() },
1497        );
1498        fake.pr_status_ok.lock().unwrap().insert(
1499            ("OwnerB".to_string(), "repoB".to_string(), 99),
1500            crate::github::PrStatus {
1501                merged: false, state: "open".into(), mergeable: Some(true),
1502                title: "t".into(), number: 99, head_sha: "abc".into(),
1503            },
1504        );
1505        // OwnerA/repoA#50 has no entry — get_pr_status errors, simulating a 404.
1506
1507        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1508        let mut session = test_session("s1", &workspace);
1509        session.repo = "OwnerA/repoA".into();
1510        session.pr_number = Some(50);
1511        store.upsert_session(&session).unwrap();
1512
1513        let engine = github_engine(store.clone(), fake.clone());
1514        let poller = Poller::new(engine);
1515
1516        poller.poll_github().await;
1517
1518        let updated = store.get_session("s1").unwrap().unwrap();
1519        assert_eq!(updated.repo, "OwnerB/repoB", "session.repo must self-heal to the remote that actually has the PR");
1520        assert_eq!(updated.pr_number, Some(99), "must adopt the mirror's own PR number, not reuse the tracked repo's number");
1521
1522        let calls = fake.calls.lock().unwrap().clone();
1523        assert_eq!(
1524            calls,
1525            vec![("OwnerA".to_string(), "repoA".to_string(), 50), ("OwnerB".to_string(), "repoB".to_string(), 99)],
1526            "the repo on record must be tried first (by its own number), the mirror only as a branch-matched fallback",
1527        );
1528    }
1529
1530    /// The bug this guards against: PR numbers are a per-repository
1531    /// sequence with no cross-repo relationship. If the recorded repo 404s,
1532    /// a *different*, unrelated repo can easily have some PR at the exact
1533    /// same number purely by coincidence. Blindly retrying the tracked
1534    /// number against that repo would silently adopt the wrong PR. Since
1535    /// that unrelated PR's head branch doesn't match this session's branch,
1536    /// the branch-matching fallback must not adopt it — even though a
1537    /// `get_pr_status` for that same number would have "succeeded".
1538    #[tokio::test]
1539    async fn poll_github_does_not_adopt_an_unrelated_pr_that_shares_the_tracked_number() {
1540        use crate::store::Store;
1541
1542        let repo_dir = init_git_repo("worker-branch", &[
1543            ("origin", "https://github.com/OwnerA/repoA.git"),
1544            ("mirror", "https://github.com/OwnerB/repoB.git"),
1545        ]);
1546        let workspace = repo_dir.path().to_string_lossy().to_string();
1547
1548        let fake = std::sync::Arc::new(FakeGithub::default());
1549        // OwnerB/repoB happens to have *some* PR #50 too, but it's unrelated
1550        // — its branch doesn't match, so no `branch_matches` entry for it.
1551        fake.pr_status_ok.lock().unwrap().insert(
1552            ("OwnerB".to_string(), "repoB".to_string(), 50),
1553            crate::github::PrStatus {
1554                merged: false, state: "open".into(), mergeable: Some(true),
1555                title: "someone else's unrelated PR".into(), number: 50, head_sha: "zzz".into(),
1556            },
1557        );
1558        // OwnerA/repoA#50 has no entry — get_pr_status errors, simulating a 404.
1559
1560        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1561        let mut session = test_session("s1", &workspace);
1562        session.repo = "OwnerA/repoA".into();
1563        session.pr_number = Some(50);
1564        store.upsert_session(&session).unwrap();
1565
1566        let engine = github_engine(store.clone(), fake.clone());
1567        let poller = Poller::new(engine);
1568
1569        poller.poll_github().await;
1570
1571        let updated = store.get_session("s1").unwrap().unwrap();
1572        assert_eq!(updated.repo, "OwnerA/repoA", "must not adopt the mirror just because it happens to have a same-numbered PR");
1573        assert_eq!(updated.pr_number, Some(50));
1574
1575        let calls = fake.calls.lock().unwrap().clone();
1576        assert!(
1577            !calls.contains(&("OwnerB".to_string(), "repoB".to_string(), 50)),
1578            "must never retry the tracked number against another repo's get_pr_status: {calls:?}",
1579        );
1580    }
1581
1582    /// When every configured remote fails, that must be visible — not just
1583    /// a `tracing::warn!` — but deduped so it doesn't spam every tick, and
1584    /// re-armed once the session recovers and then fails again.
1585    #[tokio::test]
1586    async fn poll_github_notifies_once_when_every_remote_fails_and_rearms_after_recovery() {
1587        use crate::store::Store;
1588
1589        let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/OwnerA/repoA.git")]);
1590        let workspace = repo_dir.path().to_string_lossy().to_string();
1591
1592        let fake = std::sync::Arc::new(FakeGithub::default()); // always 404s
1593
1594        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1595        let mut session = test_session("s1", &workspace);
1596        session.repo = "OwnerA/repoA".into();
1597        session.pr_number = Some(50);
1598        store.upsert_session(&session).unwrap();
1599
1600        let engine = github_engine(store.clone(), fake.clone());
1601        let mut rx = engine.subscribe();
1602        let poller = Poller::new(engine);
1603
1604        poller.poll_github().await;
1605        let events = drain_events(&mut rx);
1606        let failures = |evs: &[Event]| evs.iter().filter(|e| matches!(
1607            e, Event::Notification(n) if n.kind == crate::types::NotificationKind::GithubLookupFailed
1608        )).count();
1609        assert_eq!(failures(&events), 1, "first failure must notify");
1610
1611        poller.poll_github().await;
1612        let events = drain_events(&mut rx);
1613        assert_eq!(failures(&events), 0, "repeated failure must not re-notify");
1614
1615        // Recovery.
1616        fake.pr_status_ok.lock().unwrap().insert(
1617            ("OwnerA".to_string(), "repoA".to_string(), 50),
1618            crate::github::PrStatus {
1619                merged: false, state: "open".into(), mergeable: Some(true),
1620                title: "t".into(), number: 50, head_sha: "abc".into(),
1621            },
1622        );
1623        poller.poll_github().await;
1624
1625        // Fails again — must notify again, since recovery cleared the flag.
1626        fake.pr_status_ok.lock().unwrap().clear();
1627        poller.poll_github().await;
1628        let events = drain_events(&mut rx);
1629        assert_eq!(failures(&events), 1, "must notify again after recovering and failing anew");
1630    }
1631
1632    // ── Brain harvest ────────────────────────────────────────────────────────
1633
1634    use std::{future::Future, pin::Pin};
1635
1636    /// Records every call it receives on `calls` and resolves with a
1637    /// caller-configured outcome — never spawns a real process or touches
1638    /// the network.
1639    struct FakeHarvestRunner {
1640        calls:   tokio::sync::mpsc::UnboundedSender<(String, PathBuf, PathBuf)>,
1641        outcome: Result<(), String>,
1642    }
1643
1644    impl HarvestRunner for FakeHarvestRunner {
1645        fn run(
1646            &self,
1647            prompt:     String,
1648            workspace:  PathBuf,
1649            brain_path: PathBuf,
1650        ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>> {
1651            let _ = self.calls.send((prompt, workspace, brain_path));
1652            let outcome = self.outcome.clone();
1653            Box::pin(async move {
1654                outcome.map_err(|e| anyhow::anyhow!(e))
1655            })
1656        }
1657    }
1658
1659    /// A repo on an explicit `main` branch (so default-branch detection is
1660    /// deterministic regardless of the machine's `init.defaultBranch`),
1661    /// checked out onto `feature_branch` with an optional extra commit —
1662    /// this is the diff `compute_nontrivial_diff` sees.
1663    fn init_diff_repo(feature_branch: &str, extra_file: Option<(&str, &str)>) -> std::path::PathBuf {
1664        let dir = tempfile::tempdir().unwrap().keep();
1665        let run = |args: &[&str]| {
1666            std::process::Command::new("git")
1667                .args(["-C", dir.to_str().unwrap()])
1668                .args(args)
1669                .output()
1670                .unwrap()
1671        };
1672        run(&["init", "-q", "-b", "main"]);
1673        run(&["config", "user.email", "test@example.com"]);
1674        run(&["config", "user.name", "Test"]);
1675        std::fs::write(dir.join("README.md"), "x").unwrap();
1676        run(&["add", "."]);
1677        run(&["commit", "-q", "-m", "init"]);
1678        run(&["checkout", "-q", "-b", feature_branch]);
1679        if let Some((name, contents)) = extra_file {
1680            std::fs::write(dir.join(name), contents).unwrap();
1681            run(&["add", name]);
1682            run(&["commit", "-q", "-m", "feature work"]);
1683        }
1684        dir
1685    }
1686
1687    /// Point `NINOX_CONFIG` at a path that doesn't exist, so `AppConfig::load()`
1688    /// falls back to `AppConfig::default()` (brain harvest enabled) rather
1689    /// than risking a real config file on the machine running the tests.
1690    fn nonexistent_config_path() -> std::path::PathBuf {
1691        tempfile::tempdir().unwrap().keep().join("nonexistent-ninox-config.toml")
1692    }
1693
1694    /// A worker session whose PR was just detected, with a real non-trivial
1695    /// diff on its branch, triggers exactly one background harvest attempt —
1696    /// and never a second one on a later tick, since `pr_number.is_none()`
1697    /// has already flipped.
1698    #[allow(clippy::await_holding_lock)]
1699    #[tokio::test]
1700    async fn metadata_sync_triggers_brain_harvest_exactly_once_on_pr_detection() {
1701        use crate::{config::ENV_TEST_GUARD, store::Store};
1702
1703        let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1704        let prior = std::env::var("NINOX_CONFIG").ok();
1705        std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
1706
1707        let repo = init_diff_repo("feature-1", Some(("src.rs", "fn main() {}\n")));
1708        let workspace = repo.to_str().unwrap().to_string();
1709
1710        let sessions_dir = tempfile::tempdir().unwrap();
1711        let meta = serde_json::json!({"agentReportedPrNumber": "9"});
1712        std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
1713
1714        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1715        store.upsert_session(&test_session("s1", &workspace)).unwrap();
1716        let engine = Engine::new(store.clone());
1717
1718        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1719        let runner = Arc::new(FakeHarvestRunner { calls: tx, outcome: Ok(()) });
1720        let poller = Poller::new_with_harvest_runner(engine, runner);
1721
1722        poller.sync_sessions_metadata(sessions_dir.path()).await;
1723
1724        let (prompt, ..) = tokio::time::timeout(Duration::from_secs(2), rx.recv())
1725            .await
1726            .expect("harvest should be attempted")
1727            .expect("channel should not be closed");
1728        assert!(prompt.contains("src.rs") && prompt.contains("fn main"), "prompt must include the diff");
1729
1730        // Second tick: pr_number is already Some, so the transition guard
1731        // must not fire the harvest again.
1732        poller.sync_sessions_metadata(sessions_dir.path()).await;
1733        assert!(rx.try_recv().is_err(), "harvest must fire exactly once per session");
1734
1735        match prior {
1736            Some(v) => std::env::set_var("NINOX_CONFIG", v),
1737            None    => std::env::remove_var("NINOX_CONFIG"),
1738        }
1739    }
1740
1741    /// A worker spawned against a non-default catalogue (`session.catalogue_path`,
1742    /// set from that worker's own `NINOX_BRAIN` at spawn time — see
1743    /// `ninox_app::main::run_spawn`) must have its harvest write to that same
1744    /// catalogue, not silently fall back to the global default brain path.
1745    #[allow(clippy::await_holding_lock)]
1746    #[tokio::test]
1747    async fn metadata_sync_brain_harvest_prefers_session_catalogue_path_over_default() {
1748        use crate::{config::ENV_TEST_GUARD, store::Store};
1749
1750        let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1751        let prior = std::env::var("NINOX_CONFIG").ok();
1752        std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
1753
1754        let repo = init_diff_repo("feature-catalogue", Some(("src.rs", "fn main() {}\n")));
1755        let workspace = repo.to_str().unwrap().to_string();
1756
1757        let sessions_dir = tempfile::tempdir().unwrap();
1758        let meta = serde_json::json!({"agentReportedPrNumber": "13"});
1759        std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
1760
1761        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1762        let mut session = test_session("s1", &workspace);
1763        session.catalogue_path = Some("/custom/brain-catalogue".to_string());
1764        store.upsert_session(&session).unwrap();
1765        let engine = Engine::new(store.clone());
1766
1767        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1768        let runner = Arc::new(FakeHarvestRunner { calls: tx, outcome: Ok(()) });
1769        let poller = Poller::new_with_harvest_runner(engine, runner);
1770
1771        poller.sync_sessions_metadata(sessions_dir.path()).await;
1772
1773        let (_, _, brain_path) = tokio::time::timeout(Duration::from_secs(2), rx.recv())
1774            .await
1775            .expect("harvest should be attempted")
1776            .expect("channel should not be closed");
1777        assert_eq!(
1778            brain_path, PathBuf::from("/custom/brain-catalogue"),
1779            "harvest must target the session's own catalogue, not the global default",
1780        );
1781
1782        match prior {
1783            Some(v) => std::env::set_var("NINOX_CONFIG", v),
1784            None    => std::env::remove_var("NINOX_CONFIG"),
1785        }
1786    }
1787
1788    /// `brain_harvest.enabled = false` must suppress the harvest entirely —
1789    /// PR detection itself proceeds exactly as it would with it enabled.
1790    #[allow(clippy::await_holding_lock)]
1791    #[tokio::test]
1792    async fn metadata_sync_skips_brain_harvest_when_disabled() {
1793        use crate::{config::ENV_TEST_GUARD, store::Store};
1794
1795        let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1796        let config_dir = tempfile::tempdir().unwrap();
1797        let config_path = config_dir.path().join("config.toml");
1798        std::fs::write(&config_path, "port = 8080\nfont_size = 13.0\n\n[brain_harvest]\nenabled = false\n").unwrap();
1799        let prior = std::env::var("NINOX_CONFIG").ok();
1800        std::env::set_var("NINOX_CONFIG", &config_path);
1801
1802        let repo = init_diff_repo("feature-2", Some(("src.rs", "fn main() {}\n")));
1803        let workspace = repo.to_str().unwrap().to_string();
1804
1805        let sessions_dir = tempfile::tempdir().unwrap();
1806        let meta = serde_json::json!({"agentReportedPrNumber": "10"});
1807        std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
1808
1809        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1810        store.upsert_session(&test_session("s1", &workspace)).unwrap();
1811        let engine = Engine::new(store.clone());
1812
1813        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1814        let runner = Arc::new(FakeHarvestRunner { calls: tx, outcome: Ok(()) });
1815        let poller = Poller::new_with_harvest_runner(engine, runner);
1816
1817        poller.sync_sessions_metadata(sessions_dir.path()).await;
1818
1819        assert!(rx.try_recv().is_err(), "harvest must not fire when brain_harvest.enabled = false");
1820        let session = store.get_session("s1").unwrap().unwrap();
1821        assert_eq!(session.pr_number, Some(10), "PR detection must be unaffected by the disabled harvest");
1822        assert!(matches!(session.status, SessionStatus::PrOpen));
1823
1824        match prior {
1825            Some(v) => std::env::set_var("NINOX_CONFIG", v),
1826            None    => std::env::remove_var("NINOX_CONFIG"),
1827        }
1828    }
1829
1830    /// A session whose branch has no diff against the default branch yet
1831    /// must not trigger a harvest — nothing worth recording, and no point
1832    /// invoking an LLM call for it.
1833    #[allow(clippy::await_holding_lock)]
1834    #[tokio::test]
1835    async fn metadata_sync_skips_brain_harvest_for_trivial_diff() {
1836        use crate::{config::ENV_TEST_GUARD, store::Store};
1837
1838        let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1839        let prior = std::env::var("NINOX_CONFIG").ok();
1840        std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
1841
1842        // No extra commit — the feature branch is identical to main.
1843        let repo = init_diff_repo("feature-3", None);
1844        let workspace = repo.to_str().unwrap().to_string();
1845
1846        let sessions_dir = tempfile::tempdir().unwrap();
1847        let meta = serde_json::json!({"agentReportedPrNumber": "11"});
1848        std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
1849
1850        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1851        store.upsert_session(&test_session("s1", &workspace)).unwrap();
1852        let engine = Engine::new(store.clone());
1853
1854        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1855        let runner = Arc::new(FakeHarvestRunner { calls: tx, outcome: Ok(()) });
1856        let poller = Poller::new_with_harvest_runner(engine, runner);
1857
1858        poller.sync_sessions_metadata(sessions_dir.path()).await;
1859
1860        assert!(rx.try_recv().is_err(), "harvest must not fire for an empty diff");
1861
1862        match prior {
1863            Some(v) => std::env::set_var("NINOX_CONFIG", v),
1864            None    => std::env::remove_var("NINOX_CONFIG"),
1865        }
1866    }
1867
1868    /// A failing harvest subprocess must not affect the rest of
1869    /// `sync_sessions_metadata` — the session still transitions to `PrOpen`
1870    /// normally, and the failure is swallowed rather than propagated.
1871    #[allow(clippy::await_holding_lock)]
1872    #[tokio::test]
1873    async fn metadata_sync_survives_a_failing_brain_harvest() {
1874        use crate::{config::ENV_TEST_GUARD, store::Store};
1875
1876        let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1877        let prior = std::env::var("NINOX_CONFIG").ok();
1878        std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
1879
1880        let repo = init_diff_repo("feature-4", Some(("src.rs", "fn main() {}\n")));
1881        let workspace = repo.to_str().unwrap().to_string();
1882
1883        let sessions_dir = tempfile::tempdir().unwrap();
1884        let meta = serde_json::json!({"agentReportedPrNumber": "12"});
1885        std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
1886
1887        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1888        store.upsert_session(&test_session("s1", &workspace)).unwrap();
1889        let engine = Engine::new(store.clone());
1890
1891        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1892        let runner = Arc::new(FakeHarvestRunner {
1893            calls:   tx,
1894            outcome: Err("simulated claude -p failure".to_string()),
1895        });
1896        let poller = Poller::new_with_harvest_runner(engine, runner);
1897
1898        poller.sync_sessions_metadata(sessions_dir.path()).await;
1899
1900        let session = store.get_session("s1").unwrap().unwrap();
1901        assert_eq!(session.pr_number, Some(12), "PR detection must succeed regardless of harvest outcome");
1902        assert!(matches!(session.status, SessionStatus::PrOpen));
1903
1904        tokio::time::timeout(Duration::from_secs(2), rx.recv())
1905            .await
1906            .expect("the failing harvest must still be attempted")
1907            .expect("channel should not be closed");
1908
1909        match prior {
1910            Some(v) => std::env::set_var("NINOX_CONFIG", v),
1911            None    => std::env::remove_var("NINOX_CONFIG"),
1912        }
1913    }
1914
1915    /// Captures every `tracing` event's formatted `message` field so tests
1916    /// can assert on log output without a real logging backend.
1917    #[derive(Clone, Default)]
1918    struct CapturedLogs(Arc<std::sync::Mutex<Vec<String>>>);
1919
1920    impl CapturedLogs {
1921        fn contains(&self, needle: &str) -> bool {
1922            self.0.lock().unwrap().iter().any(|m| m.contains(needle))
1923        }
1924    }
1925
1926    impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CapturedLogs {
1927        fn on_event(&self, event: &tracing::Event<'_>, _ctx: tracing_subscriber::layer::Context<'_, S>) {
1928            struct Visitor(String);
1929            impl tracing::field::Visit for Visitor {
1930                fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
1931                    if field.name() == "message" {
1932                        self.0 = format!("{value:?}");
1933                    }
1934                }
1935            }
1936            let mut visitor = Visitor(String::new());
1937            event.record(&mut visitor);
1938            self.0.lock().unwrap().push(visitor.0);
1939        }
1940    }
1941
1942    /// A `HarvestRunner` whose returned future panics as soon as it's
1943    /// polled — stands in for a bug inside the real harvest subprocess
1944    /// plumbing, to prove a panic is logged rather than silently lost.
1945    struct PanickingHarvestRunner;
1946
1947    impl HarvestRunner for PanickingHarvestRunner {
1948        fn run(
1949            &self,
1950            _prompt: String,
1951            _workspace: PathBuf,
1952            _brain_path: PathBuf,
1953        ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>> {
1954            Box::pin(async move { panic!("simulated harvest panic") })
1955        }
1956    }
1957
1958    /// A panicking `HarvestRunner` must produce a logged warning — the
1959    /// `tokio::spawn` `JoinHandle` is otherwise discarded and a panic would
1960    /// be completely silent (see `trigger_brain_harvest`'s supervising
1961    /// spawn).
1962    #[allow(clippy::await_holding_lock)]
1963    #[tokio::test]
1964    async fn metadata_sync_logs_a_warning_when_harvest_task_panics() {
1965        use crate::{config::ENV_TEST_GUARD, store::Store};
1966        use tracing_subscriber::{layer::SubscriberExt, Registry};
1967
1968        let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1969        let prior = std::env::var("NINOX_CONFIG").ok();
1970        std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
1971
1972        let logs = CapturedLogs::default();
1973        let _log_guard = tracing::subscriber::set_default(Registry::default().with(logs.clone()));
1974
1975        let repo = init_diff_repo("feature-panic", Some(("src.rs", "fn main() {}\n")));
1976        let workspace = repo.to_str().unwrap().to_string();
1977
1978        let sessions_dir = tempfile::tempdir().unwrap();
1979        let meta = serde_json::json!({"agentReportedPrNumber": "20"});
1980        std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
1981
1982        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1983        store.upsert_session(&test_session("s1", &workspace)).unwrap();
1984        let engine = Engine::new(store.clone());
1985
1986        let poller = Poller::new_with_harvest_runner(engine, Arc::new(PanickingHarvestRunner));
1987        poller.sync_sessions_metadata(sessions_dir.path()).await;
1988
1989        // The harvest + its supervising task are detached spawns; poll
1990        // until the panic has been caught and logged, bounded so a
1991        // regression fails the test instead of hanging.
1992        let deadline = std::time::Instant::now() + Duration::from_secs(5);
1993        while !logs.contains("brain harvest task panicked") {
1994            assert!(std::time::Instant::now() < deadline, "timed out waiting for the panic to be logged");
1995            tokio::time::sleep(Duration::from_millis(20)).await;
1996        }
1997
1998        match prior {
1999            Some(v) => std::env::set_var("NINOX_CONFIG", v),
2000            None    => std::env::remove_var("NINOX_CONFIG"),
2001        }
2002    }
2003
2004    /// Records whether it ever observed two overlapping `run()` calls —
2005    /// proves the per-vault lock actually serializes concurrent harvests
2006    /// targeting the same brain path, rather than merely happening not to
2007    /// race in this particular run.
2008    struct OverlapDetectingHarvestRunner {
2009        calls:      tokio::sync::mpsc::UnboundedSender<()>,
2010        active:     Arc<std::sync::atomic::AtomicUsize>,
2011        overlapped: Arc<std::sync::atomic::AtomicBool>,
2012    }
2013
2014    impl HarvestRunner for OverlapDetectingHarvestRunner {
2015        fn run(
2016            &self,
2017            _prompt: String,
2018            _workspace: PathBuf,
2019            _brain_path: PathBuf,
2020        ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>> {
2021            let _ = self.calls.send(());
2022            let active = self.active.clone();
2023            let overlapped = self.overlapped.clone();
2024            Box::pin(async move {
2025                if active.fetch_add(1, std::sync::atomic::Ordering::SeqCst) > 0 {
2026                    overlapped.store(true, std::sync::atomic::Ordering::SeqCst);
2027                }
2028                tokio::time::sleep(Duration::from_millis(50)).await;
2029                active.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
2030                Ok(())
2031            })
2032        }
2033    }
2034
2035    /// Two sessions whose harvests target the same (default) brain vault —
2036    /// neither sets `catalogue_path`, so both resolve to
2037    /// `config.resolved_brain_path()` — must never have their
2038    /// `HarvestRunner::run` calls (which, in production, both invoke `ninox
2039    /// brain index`) overlap.
2040    #[allow(clippy::await_holding_lock)]
2041    #[tokio::test]
2042    async fn concurrent_harvests_to_the_same_vault_do_not_overlap() {
2043        use crate::{config::ENV_TEST_GUARD, store::Store};
2044
2045        let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2046        let prior = std::env::var("NINOX_CONFIG").ok();
2047        std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
2048
2049        let repo1 = init_diff_repo("feature-vault-1", Some(("a.rs", "fn a() {}\n")));
2050        let repo2 = init_diff_repo("feature-vault-2", Some(("b.rs", "fn b() {}\n")));
2051
2052        let sessions_dir = tempfile::tempdir().unwrap();
2053        for (id, pr) in [("s1", "30"), ("s2", "31")] {
2054            let meta = serde_json::json!({"agentReportedPrNumber": pr});
2055            std::fs::write(sessions_dir.path().join(format!("{id}.json")), serde_json::to_string(&meta).unwrap()).unwrap();
2056        }
2057
2058        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2059        store.upsert_session(&test_session("s1", repo1.to_str().unwrap())).unwrap();
2060        store.upsert_session(&test_session("s2", repo2.to_str().unwrap())).unwrap();
2061        let engine = Engine::new(store.clone());
2062
2063        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2064        let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2065        let overlapped = Arc::new(std::sync::atomic::AtomicBool::new(false));
2066        let runner = Arc::new(OverlapDetectingHarvestRunner {
2067            calls: tx, active: active.clone(), overlapped: overlapped.clone(),
2068        });
2069        let poller = Poller::new_with_harvest_runner(engine, runner);
2070
2071        poller.sync_sessions_metadata(sessions_dir.path()).await;
2072
2073        for _ in 0..2 {
2074            tokio::time::timeout(Duration::from_secs(2), rx.recv())
2075                .await
2076                .expect("both harvests should be attempted")
2077                .expect("channel should not be closed");
2078        }
2079        // Let the (lock-serialized) second call finish its simulated work
2080        // so `overlapped` reflects the full run.
2081        tokio::time::sleep(Duration::from_millis(150)).await;
2082
2083        assert!(
2084            !overlapped.load(std::sync::atomic::Ordering::SeqCst),
2085            "concurrent harvests to the same vault must not run HarvestRunner::run concurrently",
2086        );
2087
2088        match prior {
2089            Some(v) => std::env::set_var("NINOX_CONFIG", v),
2090            None    => std::env::remove_var("NINOX_CONFIG"),
2091        }
2092    }
2093
2094    /// Two syntactically different paths to the same physical vault (here:
2095    /// a trailing slash) must resolve to the same lock — otherwise two
2096    /// harvests using differently-spelled `catalogue_path`s for the same
2097    /// vault could still run `ninox brain index` concurrently, silently
2098    /// defeating the point of the lock.
2099    #[test]
2100    fn vault_lock_treats_equivalent_paths_as_the_same_vault() {
2101        use crate::store::Store;
2102
2103        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2104        let engine = Engine::new(store);
2105        let poller = Poller::new_with_harvest_runner(engine, Arc::new(ClaudeHarvestRunner));
2106
2107        let dir = tempfile::tempdir().unwrap();
2108        let canonical = dir.path().to_path_buf();
2109        let with_trailing_slash = PathBuf::from(format!("{}/", canonical.display()));
2110
2111        let lock_a = poller.vault_lock(&canonical);
2112        let lock_b = poller.vault_lock(&with_trailing_slash);
2113
2114        assert!(
2115            Arc::ptr_eq(&lock_a, &lock_b),
2116            "syntactically different paths to the same physical vault must share one lock",
2117        );
2118    }
2119    /// The confirmed bug: a worker's process typically exits (`Terminated`)
2120    /// once its PR is merely *open*, well before that PR merges. Merge
2121    /// detection must still run for `Terminated` sessions, not just
2122    /// `Working`/`PrOpen` — otherwise the later merge becomes permanently
2123    /// invisible the instant the process dies.
2124    #[tokio::test]
2125    async fn merge_detection_fires_for_terminated_session() {
2126        use crate::store::Store;
2127
2128        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2129        let mut s = test_session("w1", "/ws");
2130        s.status = SessionStatus::Terminated;
2131        s.orchestrator_id = Some("orch1".into());
2132        s.pr_number = Some(42);
2133        store.upsert_session(&s).unwrap();
2134
2135        let engine = Engine::new(store.clone());
2136        let mut rx = engine.subscribe();
2137        let poller = Poller::new(engine);
2138
2139        let handled = poller.handle_merge_detection(&s, 42, true).await;
2140        assert!(handled, "merge detection must run for a Terminated session");
2141
2142        let after = store.get_session("w1").unwrap().unwrap();
2143        assert!(matches!(after.status, SessionStatus::Done), "merged session transitions to Done");
2144        assert!(after.terminal_at.is_some(), "Done via merge detection must stamp terminal_at");
2145
2146        let events = drain_events(&mut rx);
2147        let merged_notifs = events.iter().filter(|e| matches!(
2148            e, Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkerDone
2149        )).count();
2150        assert_eq!(merged_notifs, 1, "exactly one WorkerDone notification for the merge");
2151    }
2152
2153    /// The orchestrator must receive exactly one worker-done reaction per
2154    /// merged session — calling merge detection again for an already-`Done`
2155    /// session (as the next poll tick would, reading the updated status back
2156    /// from the store) must be a no-op, not a duplicate notification.
2157    #[tokio::test]
2158    async fn merge_detection_does_not_fire_twice_for_the_same_session() {
2159        use crate::store::Store;
2160
2161        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2162        let mut s = test_session("w1", "/ws");
2163        s.status = SessionStatus::PrOpen;
2164        s.orchestrator_id = Some("orch1".into());
2165        s.pr_number = Some(7);
2166        store.upsert_session(&s).unwrap();
2167
2168        let engine = Engine::new(store.clone());
2169        let mut rx = engine.subscribe();
2170        let poller = Poller::new(engine);
2171
2172        let first = poller.handle_merge_detection(&s, 7, true).await;
2173        assert!(first, "first tick handles the merge");
2174
2175        // Simulate the next poll tick re-reading the (now Done) session from
2176        // the store before calling merge detection again.
2177        let updated = store.get_session("w1").unwrap().unwrap();
2178        let second = poller.handle_merge_detection(&updated, 7, true).await;
2179        assert!(!second, "an already-Done session must not re-fire merge detection");
2180
2181        let events = drain_events(&mut rx);
2182        let merged_notifs = events.iter().filter(|e| matches!(
2183            e, Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkerDone
2184        )).count();
2185        assert_eq!(merged_notifs, 1, "no duplicate WorkerDone notification across ticks");
2186    }
2187
2188    #[tokio::test]
2189    async fn handle_merge_detection_is_noop_when_pr_not_merged() {
2190        use crate::store::Store;
2191
2192        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2193        let s = test_session("w1", "/ws");
2194        store.upsert_session(&s).unwrap();
2195        let engine = Engine::new(store.clone());
2196        let poller = Poller::new(engine);
2197
2198        let handled = poller.handle_merge_detection(&s, 1, false).await;
2199        assert!(!handled);
2200        assert!(matches!(store.get_session("w1").unwrap().unwrap().status, SessionStatus::Working));
2201    }
2202
2203    /// A session past the retention window is purged; one still within the
2204    /// window survives. Time is injected via `terminal_at` (computed off the
2205    /// real clock, offset by the retention window) rather than sleeping, and
2206    /// the retention config is passed in directly rather than read from
2207    /// `AppConfig::load()` — mirroring `sync_sessions_metadata`'s pattern of
2208    /// taking its directory as a parameter so tests can control it exactly.
2209    #[tokio::test]
2210    async fn sweep_retired_sessions_purges_only_past_the_retention_window() {
2211        use crate::store::Store;
2212
2213        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2214        let retention = SessionRetentionConfig { done_retention_days: 2 };
2215        let now = now_millis();
2216        let retention_ms = retention.retention_millis();
2217
2218        let mut expired = test_session("expired", "/ws");
2219        expired.status = SessionStatus::Done;
2220        expired.terminal_at = Some(now - retention_ms - 1_000);
2221        store.upsert_session(&expired).unwrap();
2222
2223        let mut fresh = test_session("fresh", "/ws");
2224        fresh.status = SessionStatus::Done;
2225        fresh.terminal_at = Some(now - 1_000);
2226        store.upsert_session(&fresh).unwrap();
2227
2228        let engine = Engine::new(store.clone());
2229        let mut rx = engine.subscribe();
2230        let poller = Poller::new(engine);
2231
2232        poller.sweep_retired_sessions(&retention).await;
2233
2234        assert!(store.get_session("expired").unwrap().is_none(), "past-retention session must be purged");
2235        assert!(store.get_session("fresh").unwrap().is_some(), "within-retention session must survive");
2236
2237        let events = drain_events(&mut rx);
2238        assert!(events.iter().any(|e| matches!(e, Event::SessionDone(id) if id == "expired")));
2239    }
2240
2241    /// A session terminated via a direct user action
2242    /// (`terminate_session`/`remove_session`) never gets `terminal_at`
2243    /// stamped — those must stay "immediate", so the sweep purges them on
2244    /// sight rather than holding them for the grace period.
2245    #[tokio::test]
2246    async fn sweep_retired_sessions_purges_sessions_with_no_terminal_at_immediately() {
2247        use crate::store::Store;
2248
2249        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2250        let mut s = test_session("user-killed", "/ws");
2251        s.status = SessionStatus::Terminated;
2252        s.terminal_at = None;
2253        store.upsert_session(&s).unwrap();
2254
2255        let engine = Engine::new(store.clone());
2256        let poller = Poller::new(engine);
2257
2258        poller.sweep_retired_sessions(&SessionRetentionConfig::default()).await;
2259
2260        assert!(store.get_session("user-killed").unwrap().is_none());
2261    }
2262
2263    /// An orchestrator's own session row must never be auto-purged by the
2264    /// retention sweep, no matter its status or how stale `terminal_at` is.
2265    #[tokio::test]
2266    async fn sweep_retired_sessions_never_purges_orchestrator_sessions() {
2267        use crate::store::Store;
2268
2269        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2270        store.upsert_orchestrator(&crate::types::Orchestrator {
2271            id: "orch1".into(), name: "orch".into(), created_at: 0,
2272        }).unwrap();
2273        let mut s = test_session("orch1", "/ws");
2274        s.status = SessionStatus::Done;
2275        s.terminal_at = Some(0); // maximally stale
2276        store.upsert_session(&s).unwrap();
2277
2278        let engine = Engine::new(store.clone());
2279        let poller = Poller::new(engine);
2280
2281        poller.sweep_retired_sessions(&SessionRetentionConfig::default()).await;
2282
2283        assert!(store.get_session("orch1").unwrap().is_some(), "orchestrator sessions are never auto-purged");
2284    }
2285
2286    /// Active (non-terminal) sessions are left alone by the sweep regardless
2287    /// of `terminal_at`.
2288    #[tokio::test]
2289    async fn sweep_retired_sessions_ignores_non_terminal_sessions() {
2290        use crate::store::Store;
2291
2292        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2293        let mut s = test_session("still-working", "/ws");
2294        s.status = SessionStatus::Working;
2295        s.terminal_at = None;
2296        store.upsert_session(&s).unwrap();
2297
2298        let engine = Engine::new(store.clone());
2299        let poller = Poller::new(engine);
2300
2301        poller.sweep_retired_sessions(&SessionRetentionConfig::default()).await;
2302
2303        assert!(store.get_session("still-working").unwrap().is_some());
2304    }
2305}