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