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                    // Write through the live row, not the tick-start
246                    // snapshot — `deliver_work_requests` awaited above, and
247                    // the statusline process can land cost/context at any
248                    // moment (see `update_live_session_row`).
249                    let Some(written) = self.update_live_session_row(&session, |row| {
250                        row.pr_number = session.pr_number;
251                        row.status    = session.status.clone();
252                    }) else {
253                        // Deleted mid-tick — nothing below (extra-PR ledger
254                        // rows, notifications, orchestrator messages) should
255                        // run for a session that no longer exists either.
256                        continue;
257                    };
258                    self.engine.emit(Event::SessionUpdated(
259                        written, SessionFields::PR_LINK | SessionFields::STATUS,
260                    ));
261                    tracing::info!(
262                        "session {} PR #{} detected via metadata hook",
263                        session.id, first.number
264                    );
265                    self.trigger_brain_harvest(&session).await;
266                }
267            }
268
269            // -- Every reported PR beyond the tracked one --
270            let Some(tracked) = session.pr_number else { continue };
271
272            // Ledger rows first, every tick: a store error at notification
273            // time must only defer the row to the next tick, never lose it.
274            // Only write when the row id (bare PR number — collides across
275            // repos) is free: never steal another session's row.
276            for report in meta.pr_reports.iter().filter(|r| r.number != tracked) {
277                if let Ok(None) = self.engine.store.get_pr(report.number as i64) {
278                    let url = report.url.clone().unwrap_or_else(|| {
279                        format!("https://github.com/{}/pull/{}", session.repo, report.number)
280                    });
281                    let pr = PR {
282                        id:         report.number as i64,
283                        number:     report.number,
284                        title:      String::new(),
285                        url,
286                        body:       String::new(),
287                        session_id: session.id.clone(),
288                    };
289                    let _ = self.engine.store.upsert_pr(&pr);
290                    // Only reachable once per extra PR (the `get_pr` guard
291                    // above stops matching once the row exists) — this must
292                    // stay visible to the UI, not just recorded in the
293                    // store, or an agent-created duplicate PR is invisible
294                    // outside a one-off notification.
295                    self.engine.emit(Event::ExtraPrDetected(pr));
296                }
297            }
298
299            // Notifications second, deduped via the poller-owned side file.
300            let notified = hooks::read_notified_extra_prs(sessions_dir, &session.id);
301            let mut fresh: Vec<(u64, Option<String>)> = Vec::new();
302            for report in meta.pr_reports.iter()
303                .filter(|r| r.number != tracked && !notified.contains(&r.number))
304            {
305                self.engine.emit(Event::Notification(Notification {
306                    id:         format!("extra-pr-{}-{}", session.id, report.number),
307                    kind:       NotificationKind::ExtraPr,
308                    title:      format!("Extra PR — {}", session.name),
309                    body:       format!("#{} opened beyond tracked #{tracked}", report.number),
310                    session_id: Some(session.id.clone()),
311                    created_at: now_millis(),
312                }));
313                fresh.push((report.number, report.url.clone()));
314            }
315            if fresh.is_empty() {
316                continue;
317            }
318            let numbers: Vec<u64> = fresh.iter().map(|(n, _)| *n).collect();
319            if let Err(e) = hooks::mark_extra_prs_notified(sessions_dir, &session.id, &numbers) {
320                tracing::warn!("mark extra PRs notified for {}: {e}", session.id);
321            }
322            if let Some(orch) = session.orchestrator_id.clone() {
323                let msg = crate::lifecycle::reactions::format_extra_pr_reaction(
324                    &session, tracked, &fresh,
325                );
326                if let Err(e) = self.engine.send_to_session(&orch, &msg).await {
327                    tracing::warn!("send extra-PR reaction to orchestrator {orch}: {e}");
328                }
329            }
330        }
331    }
332
333    /// Fire a background brain-harvest attempt for a session whose PR was
334    /// just detected. Reuses the caller's `pr_number.is_none()` guard as its
335    /// only dedup — this is called from exactly one call site, itself
336    /// guaranteed to fire once per session lifetime, so no second dedup
337    /// layer is needed here.
338    ///
339    /// Diff computation (a couple of local `git` subprocess calls) AND the
340    /// `claude -p` subprocess itself both run inside a single `tokio::spawn`
341    /// — nothing about harvesting, however slow, may stall this poll tick's
342    /// processing of other sessions. Any failure (disabled config, no
343    /// workspace, trivial diff, or the subprocess itself failing) is logged
344    /// at most and never propagates back into `sync_sessions_metadata`. A
345    /// second, supervising spawn awaits the harvest task purely to log a
346    /// panic that would otherwise be silent.
347    async fn trigger_brain_harvest(&self, session: &Session) {
348        let config = AppConfig::load().unwrap_or_default();
349        if !config.brain_harvest.enabled {
350            return;
351        }
352        let Some(workspace) = session.workspace_path.clone() else { return };
353        let workspace_path: PathBuf = workspace.into();
354        // Prefer the session's own catalogue — set from that worker's
355        // `NINOX_BRAIN` at spawn time (see `main.rs::run_spawn`,
356        // `spawn_util::interactive_env_vars`) — over the global default, so
357        // the harvest writes to the same vault the worker itself thinks
358        // with, not always the default catalogue.
359        let brain_path: PathBuf = session.catalogue_path.clone()
360            .map(PathBuf::from)
361            .unwrap_or_else(|| config.resolved_brain_path());
362        let runner       = self.harvest_runner.clone();
363        let session_id    = session.id.clone();
364        let panic_session_id = session_id.clone();
365        let vault_lock   = self.vault_lock(&brain_path);
366
367        let handle = tokio::spawn(async move {
368            let Some(diff) = brain_harvest::compute_nontrivial_diff(&workspace_path).await else {
369                tracing::info!("brain harvest skipped for {session_id}: no non-trivial diff");
370                return;
371            };
372            let prompt = brain_harvest::build_harvest_prompt(&session_id, &diff);
373
374            // Serialize concurrent harvests that share a vault — two
375            // `claude -p` subprocesses running `ninox brain index` against
376            // the same vault at once can race on the index write.
377            let _guard = vault_lock.lock().await;
378            if let Err(e) = runner.run(prompt, workspace_path, brain_path).await {
379                tracing::warn!("brain harvest failed for session {session_id}: {e}");
380            }
381        });
382
383        tokio::spawn(async move {
384            if let Err(join_err) = handle.await {
385                if join_err.is_panic() {
386                    let payload = join_err.into_panic();
387                    tracing::warn!(
388                        "brain harvest task panicked for session {panic_session_id}: {}",
389                        panic_message(payload.as_ref()),
390                    );
391                } else {
392                    tracing::warn!("brain harvest task for session {panic_session_id} was cancelled");
393                }
394            }
395        });
396    }
397
398    /// Forward every pending `ninox request-work` entry for this session to
399    /// the UI and the orchestrator, then move it out of the pending set.
400    async fn deliver_work_requests(&self, session: &crate::types::Session, sessions_dir: &std::path::Path) {
401        let pending = match hooks::read_pending_work_requests(sessions_dir, &session.id) {
402            Ok(p) if !p.is_empty() => p,
403            _ => return,
404        };
405        for request in &pending {
406            self.engine.emit(Event::Notification(Notification {
407                id:         format!("work-request-{}", request.id),
408                kind:       NotificationKind::WorkRequested,
409                title:      format!("Work requested — {}", session.name),
410                body:       request.description.clone(),
411                session_id: Some(session.id.clone()),
412                created_at: now_millis(),
413            }));
414            if let Some(orch) = session.orchestrator_id.clone() {
415                let msg = crate::lifecycle::reactions::format_work_request_reaction(
416                    session, &request.description,
417                );
418                if let Err(e) = self.engine.send_to_session(&orch, &msg).await {
419                    tracing::warn!("send work request to orchestrator {orch}: {e}");
420                }
421            }
422        }
423        // Marked delivered even when the tmux nudge failed — the UI
424        // notification is already out, and retrying every tick would spam
425        // both channels.
426        let ids: Vec<String> = pending.iter().map(|r| r.id.clone()).collect();
427        if let Err(e) = hooks::mark_work_requests_delivered(sessions_dir, &session.id, &ids) {
428            tracing::warn!("mark work requests delivered for {}: {e}", session.id);
429        }
430    }
431
432    // ── Cost / context-window usage ─────────────────────────────────────────
433
434    /// Ingest cost/token usage for every active session by reading `claude`'s
435    /// own transcript for the session's workspace directory (see
436    /// `lifecycle::usage`). Sessions without a workspace, or whose transcript
437    /// has no usage yet (agent hasn't taken a turn), are left untouched.
438    /// Only writes + emits when something actually changed, so this doesn't
439    /// spam the store/UI every tick for idle sessions.
440    async fn poll_usage(&self) {
441        let Ok(sessions) = self.engine.store.list_sessions() else { return };
442        for mut session in sessions {
443            if matches!(session.status, SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted) {
444                continue;
445            }
446            let Some(workspace) = session.workspace_path.clone() else { continue };
447            let Some(snapshot) = usage::ingest_usage_for_workspace(&workspace) else { continue };
448
449            let cost_changed = (session.cost_usd - snapshot.cost_usd).abs() > 1e-9;
450            let context_changed = session.context_tokens != Some(snapshot.context_tokens);
451            if !cost_changed && !context_changed {
452                continue;
453            }
454
455            session.cost_usd = snapshot.cost_usd;
456            session.context_tokens = Some(snapshot.context_tokens);
457            if session.model.is_none() {
458                session.model = snapshot.model;
459            }
460            let _ = self.engine.store.upsert_session(&session);
461            self.engine.emit(Event::SessionUpdated(
462                session, SessionFields::COST | SessionFields::CONTEXT | SessionFields::MODEL,
463            ));
464        }
465    }
466
467    // ── Statusline-sourced cost/context updates (external writer) ──────────
468
469    /// The `ninox statusline` subcommand (invoked by Claude Code's own
470    /// `statusLine` hook — see `lifecycle::statusline`) writes cost/context
471    /// fields directly into the store from a separate short-lived process.
472    /// Unlike every other poll method, this data doesn't arrive via a
473    /// read-modify-write cycle this poller drives, so there's nothing to
474    /// diff against except a cache of the last-seen values. Detects
475    /// external changes and re-broadcasts them as `SessionUpdated` so the
476    /// GUI picks them up.
477    async fn poll_context_updates(&self) {
478        let Ok(sessions) = self.engine.store.list_sessions() else { return };
479        let mut changed = Vec::new();
480        {
481            let mut cache = self.context_cache.lock().unwrap();
482            for session in sessions {
483                let key = (session.cost_usd, session.context_used_pct, session.context_total_tokens);
484                // `None` means this session has never been cached — seed it
485                // silently rather than treating "no prior state" as a change
486                // (that would spam an event for every session on startup).
487                if let Some(prev) = cache.insert(session.id.clone(), key) {
488                    if prev != key {
489                        changed.push(session);
490                    }
491                }
492            }
493        }
494        for session in changed {
495            self.engine.emit(Event::SessionUpdated(
496                session, SessionFields::COST | SessionFields::CONTEXT,
497            ));
498        }
499    }
500
501    // ── GitHub enrichment ────────────────────────────────────────────────────
502
503    /// Read-modify-write against the *live* session row rather than a
504    /// tick-start `list_sessions()` snapshot. The external `ninox
505    /// statusline` process writes cost/context straight into the row from
506    /// its own process during `poll_github`'s GitHub awaits, and
507    /// `upsert_session` is a full-row write — so writing a snapshot back
508    /// would revert those fresher fields. Returns the row as written, or
509    /// `None` (write skipped) when the session was deleted mid-tick:
510    /// resurrecting a deleted row is worse than dropping one update. A
511    /// read *error* falls back to the snapshot instead — a missed
512    /// statusline write loses less than a skipped status update.
513    fn update_live_session_row(
514        &self,
515        snapshot: &crate::types::Session,
516        apply: impl FnOnce(&mut crate::types::Session),
517    ) -> Option<crate::types::Session> {
518        let mut row = match self.engine.store.get_session(&snapshot.id) {
519            Ok(Some(row)) => row,
520            Ok(None)      => return None,
521            Err(_)        => snapshot.clone(),
522        };
523        apply(&mut row);
524        let _ = self.engine.store.upsert_session(&row);
525        Some(row)
526    }
527
528    async fn poll_github(&self) {
529        let Some(gh) = &self.engine.github else { return };
530        let Ok(sessions) = self.engine.store.list_sessions() else { return };
531
532        for mut session in sessions {
533            // Only `Done` is excluded here — a session already has its PR's
534            // merge handled by definition once `Done`. `Terminated` (the
535            // worker's own process exited, typically once its PR is merely
536            // *open*) and `Interrupted` sessions may still have a PR whose
537            // fate hasn't resolved yet, so they must keep being polled or a
538            // later merge becomes permanently invisible (no status update,
539            // no notification) the instant the process dies.
540            if matches!(session.status, SessionStatus::Done) {
541                continue;
542            }
543            let Some(pr_number) = session.pr_number else { continue };
544
545            // -- PR state — try the repo on record first (the common case,
546            // no extra requests, and the only case where reusing the same
547            // numeric `pr_number` is valid: it was recorded *for that repo
548            // specifically*). If that 404s, DO NOT retry the same
549            // `pr_number` against another remote's repo — PR numbers are a
550            // per-repository sequence with no cross-repo relationship, so a
551            // different repo (e.g. an internal mirror) can easily have some
552            // unrelated PR at that same number. Instead, match on the
553            // session's actual branch, the same way `poll_pr_reconciliation`
554            // does, and adopt whatever PR number that repo's branch match
555            // actually has. --
556            let mut found: Option<(String, u64, crate::github::PrStatus)> = None;
557            let mut last_err: Option<anyhow::Error> = None;
558            let mut attempted = false;
559            if !session.repo.is_empty() {
560                if let Some((owner, repo)) = split_repo(&session.repo) {
561                    attempted = true;
562                    match gh.get_pr_status(&owner, &repo, pr_number).await {
563                        Ok(s)  => found = Some((session.repo.clone(), pr_number, s)),
564                        Err(e) => last_err = Some(e),
565                    }
566                }
567            }
568            if found.is_none() {
569                if let Some(workspace) = session.workspace_path.clone() {
570                    if let Some(branch) = crate::github::current_branch(&workspace) {
571                        for repo_slug in crate::github::candidate_repos(&workspace) {
572                            if repo_slug == session.repo {
573                                continue; // already tried above
574                            }
575                            let Some((owner, repo)) = split_repo(&repo_slug) else { continue };
576                            attempted = true;
577                            let pr_ref = match gh.find_open_pr_for_branch(&owner, &repo, &branch).await {
578                                Ok(Some(r)) => r,
579                                Ok(None)    => continue,
580                                Err(e)      => { last_err = Some(e); continue; }
581                            };
582                            match gh.get_pr_status(&owner, &repo, pr_ref.number).await {
583                                Ok(s)  => { found = Some((repo_slug, pr_ref.number, s)); break; }
584                                Err(e) => { last_err = Some(e); continue; }
585                            }
586                        }
587                    }
588                }
589            }
590            if !attempted {
591                continue; // nothing parseable to check — same as pre-fallback behavior
592            }
593            let Some((resolved_repo, pr_number, pr_status)) = found else {
594                if let Some(e) = last_err {
595                    tracing::warn!("github pr status for {}: {e}", session.id);
596                }
597                self.notify_github_lookup_failed(&session);
598                continue;
599            };
600            self.clear_github_lookup_failed(&session.id);
601
602            let pr_id: PrId = pr_number as i64;
603
604            // Self-heal (repo/pr_number) and pr_id are persisted together,
605            // in one write, before merge detection below — which can
606            // `continue` the loop and move the session to `Done`, a status
607            // this function never revisits (see the guard at the top of the
608            // loop). If pr_id were only set further down (after merge
609            // detection), a PR that turns out to already be merged on the
610            // very tick it's discovered would leave the session stuck with
611            // pr_number set but pr_id permanently None/stale — same
612            // flicker-causing inconsistency as the bug this function is
613            // otherwise fixing, just for a session that never gets polled
614            // again to self-correct.
615            if resolved_repo != session.repo
616                || Some(pr_number) != session.pr_number
617                || session.pr_id != Some(pr_id)
618            {
619                if resolved_repo != session.repo || Some(pr_number) != session.pr_number {
620                    tracing::info!(
621                        "session {} repo/PR corrected {}#{:?} -> {resolved_repo}#{pr_number}",
622                        session.id, session.repo, session.pr_number,
623                    );
624                }
625                session.repo = resolved_repo;
626                session.pr_number = Some(pr_number);
627                session.pr_id = Some(pr_id);
628                let written = self.update_live_session_row(&session, |row| {
629                    row.repo = session.repo.clone();
630                    row.pr_number = session.pr_number;
631                    row.pr_id = session.pr_id;
632                });
633                if written.is_some() {
634                    // This emit is the *only* channel by which self-healed pr
635                    // fields reach the GUI — the status/gate emit further down
636                    // deliberately flags only STATUS|GATE (it persists nothing
637                    // else). PR_LINK alone restricts the receiving `merge_from`
638                    // to exactly the three fields this write just corrected.
639                    self.engine.emit(Event::SessionUpdated(
640                        session.clone(), SessionFields::PR_LINK,
641                    ));
642                }
643            }
644            let Some((owner, repo)) = split_repo(&session.repo) else { continue };
645
646            // -- Merge detection — handle before CI (no point polling CI on merged PR) --
647            if self.handle_merge_detection(&session, pr_number, pr_status.merged).await {
648                // Skips the gate-computation block below — a merged session's
649                // gate_status is intentionally left frozen at its last
650                // pre-merge value, not recomputed at the merging tick.
651                continue; // skip further enrichment for this session
652            }
653
654            // Upsert PR record — only when not merged (merged sessions stay Done after cleanup)
655            {
656                let pr = PR {
657                    id:         pr_id,
658                    number:     pr_number,
659                    title:      pr_status.title.clone(),
660                    url:        format!("https://github.com/{owner}/{repo}/pull/{pr_number}"),
661                    body:       String::new(),
662                    session_id: session.id.clone(),
663                };
664                let _ = self.engine.store.upsert_pr(&pr);
665                self.engine.emit(Event::PrOpened { session_id: session.id.clone(), pr });
666            }
667
668            // -- CI checks --
669            let checks = match gh.get_ci_checks(&owner, &repo, &pr_status.head_sha).await {
670                Ok(c)  => c,
671                Err(e) => { tracing::warn!("github ci checks: {e}"); vec![] }
672            };
673            let ci = summarize_checks(pr_id, &checks);
674            let _ = self.engine.store.upsert_ci_status(&ci);
675            self.engine.emit(Event::CiUpdated { pr_id, status: ci.clone() });
676
677            // -- Detect CI transition and update session status --
678            let (newly_failing, ci_reaction_already_sent) = {
679                let mut cache = self.enrichment_cache.lock().unwrap();
680                let state = cache.entry(session.id.clone()).or_default();
681
682                let newly_failing = state.prev_failing.is_none_or(|p| p == 0)
683                    && ci.failing > 0;
684                state.prev_failing = Some(ci.failing);
685
686                let already_sent = state.ci_reaction_sent;
687                if newly_failing && !already_sent {
688                    state.ci_reaction_sent = true;
689                }
690                if ci.failing == 0 {
691                    state.ci_reaction_sent = false;
692                }
693                (newly_failing, already_sent)
694            };
695
696            if newly_failing && !ci_reaction_already_sent {
697                self.engine.emit(Event::Notification(Notification {
698                    id:         format!("ci-{}", session.id),
699                    kind:       NotificationKind::CiFailure,
700                    title:      format!("CI failing — {}", session.name),
701                    body:       format!("{}/{} checks failing", ci.failing, ci.total),
702                    session_id: Some(session.id.clone()),
703                    created_at: now_millis(),
704                }));
705                // Send reaction to the agent in the tmux session
706                let failing_names: Vec<String> = checks.iter()
707                    .filter(|c| c.conclusion.as_deref() == Some("failure")
708                             || c.conclusion.as_deref() == Some("timed_out"))
709                    .map(|c| c.name.clone())
710                    .collect();
711                let msg = crate::lifecycle::reactions::format_ci_reaction(
712                    &session, &ci, &failing_names
713                );
714                if let Err(e) = self.engine.send_to_session(&session.id, &msg).await {
715                    tracing::warn!("send ci reaction to {}: {e}", session.id);
716                }
717            }
718
719            // -- Review threads + issue comments (throttled via seen_comment_ids) --
720            let threads = match gh.get_review_threads(&owner, &repo, pr_number).await {
721                Ok(t)  => t,
722                Err(e) => { tracing::warn!("github review threads: {e}"); vec![] }
723            };
724            let issue_comments = match gh.get_issue_comments(&owner, &repo, pr_number).await {
725                Ok(c)  => c,
726                Err(e) => { tracing::warn!("github issue comments: {e}"); vec![] }
727            };
728
729            let has_changes_requested = threads.iter().any(|t| t.state == "CHANGES_REQUESTED");
730
731            let (has_new, review_reaction_already_sent, new_comments) = {
732                let mut cache = self.enrichment_cache.lock().unwrap();
733                let state = cache.entry(session.id.clone()).or_default();
734                let mut has_new = false;
735                let mut new_comments: Vec<Comment> = Vec::new();
736
737                // Persist + emit every displayable comment — CHANGES_REQUESTED
738                // and plain COMMENTED reviews (which also covers inline diff
739                // comments, tagged COMMENTED by `get_review_threads`) — so the
740                // Info panel's Marginalia feed shows the whole conversation.
741                // `has_new`/`new_comments` stay CHANGES_REQUESTED-only: they
742                // drive the reaction/notification path below, which must not
743                // widen just because the display feed did. A bare
744                // empty-body "Comment" review (whose only content is inline
745                // comments, already captured separately) is skipped so the
746                // feed doesn't show blank entries — but never for
747                // CHANGES_REQUESTED, which must keep being captured (and
748                // reacted to) exactly as before regardless of body content.
749                for thread in &threads {
750                    let is_changes_requested = thread.state == "CHANGES_REQUESTED";
751                    let is_displayable = is_changes_requested || thread.state == "COMMENTED";
752                    if !is_displayable || state.seen_comment_ids.contains(&thread.id) {
753                        continue;
754                    }
755                    if !is_changes_requested && thread.body.trim().is_empty() {
756                        continue;
757                    }
758                    state.seen_comment_ids.insert(thread.id);
759                    let comment = Comment {
760                        id:         thread.id,
761                        pr_id,
762                        author:     thread.author.clone(),
763                        body:       thread.body.clone(),
764                        path:       thread.path.clone(),
765                        line:       thread.line,
766                        created_at: thread.created_at,
767                    };
768                    let _ = self.engine.store.upsert_comment(&comment);
769                    self.engine.emit(Event::ReviewComment { pr_id, comment: comment.clone() });
770                    if is_changes_requested {
771                        has_new = true;
772                        new_comments.push(comment);
773                    }
774                }
775
776                for issue_comment in &issue_comments {
777                    if state.seen_comment_ids.contains(&issue_comment.id) {
778                        continue;
779                    }
780                    state.seen_comment_ids.insert(issue_comment.id);
781                    let comment = Comment { pr_id, ..issue_comment.clone() };
782                    let _ = self.engine.store.upsert_comment(&comment);
783                    self.engine.emit(Event::ReviewComment { pr_id, comment });
784                }
785
786                let already_sent = state.review_reaction_sent;
787                if has_new && !already_sent {
788                    state.review_reaction_sent = true;
789                }
790                // Reset when all CHANGES_REQUESTED are resolved
791                if !has_changes_requested {
792                    state.review_reaction_sent = false;
793                }
794                (has_new, already_sent, new_comments)
795            };
796
797            // Update session status in DB (after review threads so has_changes_requested is known)
798            let new_status = derive_session_status(&session.status, &pr_status, &ci, has_changes_requested);
799            let new_gate = compute_new_gate(
800                &session.status, &ci, has_changes_requested, pr_status.mergeable,
801                session.gate_status.as_ref(), now_millis(),
802            );
803            if new_status != session.status || new_gate != session.gate_status {
804                // `session` is the tick-start snapshot, several GitHub
805                // awaits old — write through the live row instead (see
806                // `update_live_session_row`), and skip both write and emit
807                // if the session was deleted mid-tick.
808                if let Some(updated) = self.update_live_session_row(&session, |row| {
809                    row.status = new_status;
810                    row.gate_status = new_gate;
811                }) {
812                    self.engine.emit(Event::SessionUpdated(
813                        updated,
814                        SessionFields::STATUS | SessionFields::GATE,
815                    ));
816                }
817            }
818
819            if has_new && !review_reaction_already_sent {
820                self.engine.emit(Event::Notification(Notification {
821                    id:         format!("review-{}", session.id),
822                    kind:       NotificationKind::PrNeedsAttention,
823                    title:      format!("Review comments — {}", session.name),
824                    body:       "Changes requested on your PR".to_string(),
825                    session_id: Some(session.id.clone()),
826                    created_at: now_millis(),
827                }));
828                if !new_comments.is_empty() {
829                    let msg = crate::lifecycle::reactions::format_review_reaction(
830                        &session, &new_comments
831                    );
832                    if let Err(e) = self.engine.send_to_session(&session.id, &msg).await {
833                        tracing::warn!("send review reaction to {}: {e}", session.id);
834                    }
835                }
836            }
837        }
838    }
839
840    /// Emit a `GithubLookupFailed` notification once per run of consecutive
841    /// failures — deduped the same way `ci_reaction_sent` dedupes CI-failure
842    /// reactions, via the enrichment cache.
843    fn notify_github_lookup_failed(&self, session: &crate::types::Session) {
844        let already_notified = {
845            let mut cache = self.enrichment_cache.lock().unwrap();
846            let state = cache.entry(session.id.clone()).or_default();
847            let already = state.github_lookup_failed_notified;
848            state.github_lookup_failed_notified = true;
849            already
850        };
851        if already_notified {
852            return;
853        }
854        self.engine.emit(Event::Notification(Notification {
855            id:         format!("github-lookup-failed-{}", session.id),
856            kind:       NotificationKind::GithubLookupFailed,
857            title:      format!("GitHub lookup failing — {}", session.name),
858            body:       "PR status/CI/review polling failed against every configured remote"
859                .to_string(),
860            session_id: Some(session.id.clone()),
861            created_at: now_millis(),
862        }));
863    }
864
865    /// Clear the dedup flag once a lookup succeeds again — the next failure
866    /// (if any) gets its own fresh notification.
867    fn clear_github_lookup_failed(&self, session_id: &str) {
868        let mut cache = self.enrichment_cache.lock().unwrap();
869        if let Some(state) = cache.get_mut(session_id) {
870            state.github_lookup_failed_notified = false;
871        }
872    }
873
874    // ── PR reconciliation (active fallback) ─────────────────────────────────
875
876    /// For every non-terminal session that has no tracked PR yet, actively
877    /// check whether a PR already exists for its branch — independent of
878    /// whether the wrapped `gh pr create` ever ran (a manual `git push` +
879    /// PR opened via the GitHub web UI, `hub`, a shell alias, or the wrapper
880    /// simply missing an unrecognized `gh` invocation shape all leave
881    /// `pr_number` at `None` forever without this). Tries every configured
882    /// remote, not just `origin`, for the same dual-remote reason as
883    /// `poll_github`'s fallback.
884    async fn poll_pr_reconciliation(&self) {
885        let Some(gh) = &self.engine.github else { return };
886        let Ok(sessions) = self.engine.store.list_sessions() else { return };
887
888        for mut session in sessions {
889            if session.pr_number.is_some() {
890                continue;
891            }
892            if matches!(session.status, SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted) {
893                continue;
894            }
895            let Some(workspace) = session.workspace_path.clone() else { continue };
896            let Some(branch) = crate::github::current_branch(&workspace) else { continue };
897
898            for repo_slug in crate::github::candidate_repos(&workspace) {
899                let Some((owner, repo)) = split_repo(&repo_slug) else { continue };
900                match gh.find_open_pr_for_branch(&owner, &repo, &branch).await {
901                    Ok(Some(pr_ref)) => {
902                        session.pr_number = Some(pr_ref.number);
903                        session.repo      = repo_slug.clone();
904                        session.status    = SessionStatus::PrOpen;
905                        // Write through the live row, not the tick-start
906                        // snapshot — `find_open_pr_for_branch` is a network
907                        // await, plenty of time for the statusline process to
908                        // land cost/context (see `update_live_session_row`).
909                        if let Some(written) = self.update_live_session_row(&session, |row| {
910                            row.pr_number = session.pr_number;
911                            row.repo      = session.repo.clone();
912                            row.status    = session.status.clone();
913                        }) {
914                            self.engine.emit(Event::SessionUpdated(
915                                written, SessionFields::PR_LINK | SessionFields::STATUS,
916                            ));
917                            tracing::info!(
918                                "session {} PR #{} detected via reconciliation ({repo_slug}, branch {branch})",
919                                session.id, pr_ref.number,
920                            );
921                        }
922                        break;
923                    }
924                    Ok(None) => continue,
925                    Err(e) => {
926                        tracing::warn!("reconciliation lookup {repo_slug} branch {branch}: {e}");
927                        continue;
928                    }
929                }
930            }
931        }
932    }
933    /// Handle a freshly-fetched PR status's merge state for `session`.
934    /// Returns `true` when the merge was handled this call (the caller
935    /// should skip further CI/review enrichment for the session this tick);
936    /// `false` when there's nothing to do (not merged, or already `Done` —
937    /// reusing the status transition itself as the one-shot dedup guard, so
938    /// a session can never fire this reaction twice).
939    ///
940    /// Extracted from `poll_github` so it's directly unit-testable without a
941    /// live GitHub client — `poll_github` only needs the client to fetch
942    /// `pr_status` in the first place; this handles the resulting state
943    /// unconditionally.
944    async fn handle_merge_detection(
945        &self,
946        session:    &Session,
947        pr_number:  u64,
948        pr_merged:  bool,
949    ) -> bool {
950        if !pr_merged || matches!(session.status, SessionStatus::Done) {
951            return false;
952        }
953        self.engine.emit(Event::Notification(Notification {
954            id:         format!("merged-{}", session.id),
955            kind:       NotificationKind::WorkerDone,
956            title:      format!("PR merged — {}", session.name),
957            body:       format!("#{} merged successfully", pr_number),
958            session_id: Some(session.id.clone()),
959            created_at: now_millis(),
960        }));
961        // Code-level completion guarantee for the orchestrator — independent
962        // of whether the worker's own agent ever reports back before exiting.
963        if let Some(orch) = session.orchestrator_id.clone() {
964            let msg = crate::lifecycle::reactions::format_worker_done_reaction(session, pr_number);
965            if let Err(e) = self.engine.send_to_session(&orch, &msg).await {
966                tracing::warn!("send worker-done reaction to orchestrator {orch}: {e}");
967            }
968        }
969        if let Err(e) = self.engine.cleanup_session(&session.id).await {
970            tracing::warn!("cleanup_session {}: {e}", session.id);
971        }
972        // Remove enrichment state for this session — it's done
973        {
974            let mut cache = self.enrichment_cache.lock().unwrap();
975            cache.remove(&session.id);
976        }
977        true
978    }
979
980    // ── Retention sweep ──────────────────────────────────────────────────────
981
982    /// Purge `Done`/`Terminated` session records whose terminal state was
983    /// reached more than `retention`'s window ago — gives the fleet board a
984    /// grace period to show what just completed instead of the record
985    /// vanishing the instant the poller marks it done (see
986    /// `Session::terminal_at`). Sessions with no `terminal_at` (a terminal
987    /// state reached via a direct user action —
988    /// `Engine::terminate_session`/`Engine::remove_session` — rather than
989    /// this automatic lifecycle path) have no grace period and are purged
990    /// on sight, preserving today's immediate disappearance for those
991    /// actions. Orchestrator sessions are never purged this way.
992    async fn sweep_retired_sessions(&self, retention: &SessionRetentionConfig) {
993        let Ok(sessions) = self.engine.store.list_sessions() else { return };
994        let Ok(orchestrators) = self.engine.store.list_orchestrators() else { return };
995        let orch_ids: std::collections::HashSet<&str> =
996            orchestrators.iter().map(|o| o.id.as_str()).collect();
997
998        let now = now_millis();
999        let retention_ms = retention.retention_millis();
1000
1001        for session in sessions {
1002            if !matches!(session.status, SessionStatus::Done | SessionStatus::Terminated) {
1003                continue;
1004            }
1005            if orch_ids.contains(session.id.as_str()) {
1006                continue;
1007            }
1008            let expired = match session.terminal_at {
1009                Some(t) => now.saturating_sub(t) >= retention_ms,
1010                None    => true,
1011            };
1012            if !expired {
1013                continue;
1014            }
1015            // `Done` sessions only ever get there via `handle_merge_detection`
1016            // (see `poll_github`), which already sent
1017            // `format_worker_done_reaction` to the orchestrator before
1018            // transitioning the status — notifying again here would be a
1019            // duplicate. `Terminated` sessions (worker process died on its
1020            // own via `poll_pids`, or a direct `terminate_session`) have
1021            // never been told anything — this is their one and only chance
1022            // before the record disappears for good.
1023            if matches!(session.status, SessionStatus::Terminated) {
1024                self.engine.emit(Event::Notification(Notification {
1025                    id:         format!("retired-{}", session.id),
1026                    kind:       NotificationKind::WorkerRetired,
1027                    title:      format!("Worker retired — {}", session.name),
1028                    body:       "Session cleaned up without a detected PR merge".to_string(),
1029                    session_id: Some(session.id.clone()),
1030                    created_at: now,
1031                }));
1032                if let Some(orch) = session.orchestrator_id.clone() {
1033                    let msg = crate::lifecycle::reactions::format_worker_retired_reaction(&session);
1034                    if let Err(e) = self.engine.send_to_session(&orch, &msg).await {
1035                        tracing::warn!("send worker-retired reaction to orchestrator {orch}: {e}");
1036                    }
1037                }
1038            }
1039            // Delegate to `remove_session` rather than deleting the row
1040            // directly — it also kills any lingering tmux session and
1041            // removes the worktree/artifacts, so a session that never went
1042            // through `handle_merge_detection`'s cleanup still gets it here.
1043            if let Err(e) = self.engine.remove_session(&session.id).await {
1044                tracing::warn!("purge retired session {}: {e}", session.id);
1045                continue;
1046            }
1047        }
1048    }
1049}
1050
1051// ── Helpers ──────────────────────────────────────────────────────────────────
1052
1053fn summarize_checks(pr_id: PrId, checks: &[CheckRun]) -> CIStatus {
1054    let total   = checks.len() as u32;
1055    let failing = checks.iter().filter(|c| {
1056        c.conclusion.as_deref() == Some("failure")
1057            || c.conclusion.as_deref() == Some("timed_out")
1058    }).count() as u32;
1059    let passing = checks.iter().filter(|c| {
1060        c.conclusion.as_deref() == Some("success")
1061    }).count() as u32;
1062    let pending = total - failing - passing;
1063    CIStatus { pr_id, total, failing, passing, pending }
1064}
1065
1066fn derive_session_status(
1067    current:               &SessionStatus,
1068    pr_status:             &crate::github::PrStatus,
1069    ci:                    &CIStatus,
1070    has_changes_requested: bool,
1071) -> SessionStatus {
1072    // Terminal states are never overwritten.
1073    if matches!(current, SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted) {
1074        return current.clone();
1075    }
1076    if pr_status.merged {
1077        return SessionStatus::Done;
1078    }
1079    if ci.failing > 0 {
1080        return SessionStatus::CiFailed;
1081    }
1082    if has_changes_requested {
1083        return SessionStatus::ReviewPending;
1084    }
1085    if pr_status.mergeable == Some(true) && ci.failing == 0 && ci.pending == 0 {
1086        return SessionStatus::Mergeable;
1087    }
1088    SessionStatus::PrOpen
1089}
1090
1091fn derive_gate_status(
1092    ci:                    &CIStatus,
1093    has_changes_requested: bool,
1094    mergeable:             Option<bool>,
1095    previous:              Option<&GateStatus>,
1096    now:                   i64,
1097) -> GateStatus {
1098    let ci_check = if ci.failing > 0 {
1099        GateCheck::Failing
1100    } else if ci.pending > 0 {
1101        GateCheck::Pending
1102    } else {
1103        GateCheck::Passing
1104    };
1105    let review_check = if has_changes_requested { GateCheck::Failing } else { GateCheck::Passing };
1106    let mergeable_check = match mergeable {
1107        Some(true)  => GateCheck::Passing,
1108        Some(false) => GateCheck::Failing,
1109        None        => GateCheck::Unknown,
1110    };
1111
1112    let unchanged = previous.is_some_and(|p| {
1113        p.ci == ci_check && p.review == review_check && p.mergeable == mergeable_check
1114    });
1115    let since = if unchanged { previous.unwrap().since } else { now };
1116
1117    GateStatus { ci: ci_check, review: review_check, mergeable: mergeable_check, since }
1118}
1119
1120/// Wraps `derive_gate_status` with the same terminal guard
1121/// `derive_session_status` applies to `SessionStatus`: once a session has
1122/// reached a terminal status (`Done`/`Terminated`/`Interrupted`), its
1123/// `GateStatus` must never be recomputed — it stays exactly as last
1124/// observed. `poll_github` deliberately keeps polling `Terminated` and
1125/// `Interrupted` sessions with an unresolved PR (see the comment on its
1126/// status guard) purely to catch a late merge; that must not have the side
1127/// effect of drifting `gate_status` off live CI/review/mergeable data that
1128/// no longer reflects a session anyone is actively working.
1129///
1130/// `current_status` must be the status *entering* this tick — i.e.
1131/// `session.status` before this tick's `derive_session_status` call — not
1132/// the freshly derived one, so a session's very last live tick (the one
1133/// that flips it to terminal) still gets to compute its final gate.
1134fn compute_new_gate(
1135    current_status:        &SessionStatus,
1136    ci:                    &CIStatus,
1137    has_changes_requested: bool,
1138    mergeable:             Option<bool>,
1139    previous_gate:         Option<&GateStatus>,
1140    now:                   i64,
1141) -> Option<GateStatus> {
1142    if matches!(current_status, SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted) {
1143        return previous_gate.cloned();
1144    }
1145    Some(derive_gate_status(ci, has_changes_requested, mergeable, previous_gate, now))
1146}
1147
1148#[cfg(test)]
1149mod tests {
1150    use super::*;
1151    use crate::types::SessionStatus;
1152
1153    #[test]
1154    fn summarize_checks_counts_failures() {
1155        let checks = vec![
1156            CheckRun { name: "lint".into(), status: "completed".into(), conclusion: Some("success".into()) },
1157            CheckRun { name: "test".into(), status: "completed".into(), conclusion: Some("failure".into()) },
1158            CheckRun { name: "build".into(), status: "in_progress".into(), conclusion: None },
1159        ];
1160        let ci = summarize_checks(1, &checks);
1161        assert_eq!(ci.total,   3);
1162        assert_eq!(ci.passing, 1);
1163        assert_eq!(ci.failing, 1);
1164        assert_eq!(ci.pending, 1);
1165    }
1166
1167    #[test]
1168    fn derive_status_merged_becomes_done() {
1169        let pr = crate::github::PrStatus {
1170            merged: true, state: "closed".into(), mergeable: None,
1171            title: "t".into(), number: 1, head_sha: String::new(),
1172        };
1173        let ci = CIStatus { pr_id: 1, total: 0, failing: 0, passing: 0, pending: 0 };
1174        let s  = derive_session_status(&SessionStatus::PrOpen, &pr, &ci, false);
1175        assert!(matches!(s, SessionStatus::Done));
1176    }
1177
1178    #[test]
1179    fn derive_status_ci_failure_overrides_open() {
1180        let pr = crate::github::PrStatus {
1181            merged: false, state: "open".into(), mergeable: Some(true),
1182            title: "t".into(), number: 1, head_sha: String::new(),
1183        };
1184        let ci = CIStatus { pr_id: 1, total: 3, failing: 1, passing: 2, pending: 0 };
1185        let s  = derive_session_status(&SessionStatus::PrOpen, &pr, &ci, false);
1186        assert!(matches!(s, SessionStatus::CiFailed));
1187    }
1188
1189    #[test]
1190    fn derive_status_all_green_becomes_mergeable() {
1191        let pr = crate::github::PrStatus {
1192            merged: false, state: "open".into(), mergeable: Some(true),
1193            title: "t".into(), number: 1, head_sha: String::new(),
1194        };
1195        let ci = CIStatus { pr_id: 1, total: 3, failing: 0, passing: 3, pending: 0 };
1196        let s  = derive_session_status(&SessionStatus::PrOpen, &pr, &ci, false);
1197        assert!(matches!(s, SessionStatus::Mergeable));
1198    }
1199
1200    #[test]
1201    fn derive_gate_status_maps_raw_signals() {
1202        let ci = CIStatus { pr_id: 1, total: 3, passing: 1, failing: 1, pending: 1 };
1203        let gate = derive_gate_status(&ci, true, Some(false), None, 1_000);
1204        assert!(matches!(gate.ci, GateCheck::Failing));
1205        assert!(matches!(gate.review, GateCheck::Failing));
1206        assert!(matches!(gate.mergeable, GateCheck::Failing));
1207        assert_eq!(gate.since, 1_000, "first observation stamps `since` to `now`");
1208    }
1209
1210    #[test]
1211    fn derive_gate_status_maps_all_passing() {
1212        let ci = CIStatus { pr_id: 1, total: 2, passing: 2, failing: 0, pending: 0 };
1213        let gate = derive_gate_status(&ci, false, Some(true), None, 1_000);
1214        assert!(matches!(gate.ci, GateCheck::Passing));
1215        assert!(matches!(gate.review, GateCheck::Passing));
1216        assert!(matches!(gate.mergeable, GateCheck::Passing));
1217    }
1218
1219    #[test]
1220    fn derive_gate_status_maps_pending_ci_and_unknown_mergeable() {
1221        let ci = CIStatus { pr_id: 1, total: 2, passing: 0, failing: 0, pending: 2 };
1222        let gate = derive_gate_status(&ci, false, None, None, 1_000);
1223        assert!(matches!(gate.ci, GateCheck::Pending));
1224        assert!(matches!(gate.mergeable, GateCheck::Unknown));
1225    }
1226
1227    #[test]
1228    fn derive_gate_status_carries_since_forward_when_unchanged() {
1229        let ci = CIStatus { pr_id: 1, total: 1, passing: 1, failing: 0, pending: 0 };
1230        let previous = GateStatus {
1231            ci: GateCheck::Passing, review: GateCheck::Passing,
1232            mergeable: GateCheck::Passing, since: 500,
1233        };
1234        let gate = derive_gate_status(&ci, false, Some(true), Some(&previous), 9_999);
1235        assert_eq!(gate.since, 500, "unchanged combination keeps the original `since`");
1236    }
1237
1238    #[test]
1239    fn derive_gate_status_resets_since_when_combination_changes() {
1240        let ci = CIStatus { pr_id: 1, total: 1, passing: 0, failing: 1, pending: 0 };
1241        let previous = GateStatus {
1242            ci: GateCheck::Passing, review: GateCheck::Passing,
1243            mergeable: GateCheck::Passing, since: 500,
1244        };
1245        let gate = derive_gate_status(&ci, false, Some(true), Some(&previous), 9_999);
1246        assert!(matches!(gate.ci, GateCheck::Failing));
1247        assert_eq!(gate.since, 9_999, "a changed combination resets `since` to `now`");
1248    }
1249
1250    /// The bug this guards: a `Terminated`/`Interrupted` session with an
1251    /// unresolved PR keeps being polled (see the comment on `poll_github`'s
1252    /// status guard) purely to catch a late merge. That must not have the
1253    /// side effect of recomputing `gate_status` from live CI/review/mergeable
1254    /// data — the gate must stay exactly as last observed, mirroring
1255    /// `derive_session_status`'s own terminal guard.
1256    #[test]
1257    fn compute_new_gate_preserves_terminal_gate_even_when_live_signals_changed() {
1258        let previous = GateStatus {
1259            ci: GateCheck::Failing, review: GateCheck::Failing,
1260            mergeable: GateCheck::Failing, since: 111,
1261        };
1262        // Live signals now look all-green — if the terminal guard were
1263        // missing, `derive_gate_status` would flip the gate to all-Passing
1264        // and reset `since` to `now`.
1265        let ci = CIStatus { pr_id: 1, total: 3, passing: 3, failing: 0, pending: 0 };
1266
1267        for status in [SessionStatus::Terminated, SessionStatus::Interrupted, SessionStatus::Done] {
1268            let gate = compute_new_gate(&status, &ci, false, Some(true), Some(&previous), 9_999);
1269            assert_eq!(
1270                gate, Some(previous.clone()),
1271                "{status:?} session's gate must be carried forward unchanged, not recomputed",
1272            );
1273        }
1274    }
1275
1276    #[test]
1277    fn compute_new_gate_recomputes_for_non_terminal_status() {
1278        let previous = GateStatus {
1279            ci: GateCheck::Failing, review: GateCheck::Failing,
1280            mergeable: GateCheck::Failing, since: 111,
1281        };
1282        let ci = CIStatus { pr_id: 1, total: 3, passing: 3, failing: 0, pending: 0 };
1283
1284        let gate = compute_new_gate(&SessionStatus::PrOpen, &ci, false, Some(true), Some(&previous), 9_999);
1285        let gate = gate.expect("non-terminal status must produce a fresh gate");
1286        assert!(matches!(gate.ci, GateCheck::Passing), "must reflect the live all-green signals");
1287        assert_eq!(gate.since, 9_999, "changed combination resets `since` to `now`");
1288    }
1289
1290    #[test]
1291    fn derive_status_preserves_done() {
1292        let pr = crate::github::PrStatus {
1293            merged: false, state: "open".into(), mergeable: Some(true),
1294            title: "t".into(), number: 1, head_sha: String::new(),
1295        };
1296        let ci = CIStatus { pr_id: 1, total: 0, failing: 0, passing: 0, pending: 0 };
1297        let s  = derive_session_status(&SessionStatus::Done, &pr, &ci, false);
1298        assert!(matches!(s, SessionStatus::Done));
1299    }
1300
1301    #[test]
1302    fn derive_status_preserves_terminated() {
1303        let pr = crate::github::PrStatus {
1304            merged: true, state: "closed".into(), mergeable: None,   // merged=true!
1305            title: "t".into(), number: 1, head_sha: String::new(),
1306        };
1307        let ci = CIStatus { pr_id: 1, total: 0, failing: 0, passing: 0, pending: 0 };
1308        let s  = derive_session_status(&SessionStatus::Terminated, &pr, &ci, false);
1309        assert!(matches!(s, SessionStatus::Terminated));  // must not become Done
1310    }
1311
1312    #[test]
1313    fn derive_status_changes_requested_becomes_review_pending() {
1314        let pr = crate::github::PrStatus {
1315            merged: false, state: "open".into(), mergeable: Some(true),
1316            title: "t".into(), number: 1, head_sha: String::new(),
1317        };
1318        let ci = CIStatus { pr_id: 1, total: 3, failing: 0, passing: 3, pending: 0 };
1319        let s  = derive_session_status(&SessionStatus::PrOpen, &pr, &ci, true);
1320        assert!(matches!(s, SessionStatus::ReviewPending));
1321    }
1322
1323    fn test_session(id: &str, workspace: &str) -> crate::types::Session {
1324        crate::types::Session {
1325            id: id.into(), orchestrator_id: None, name: id.into(),
1326            repo: String::new(), status: SessionStatus::Working,
1327            agent_type: "claude-code".into(), cost_usd: 0.0, started_at: 0,
1328            pr_number: None, pr_id: None,
1329            workspace_path: Some(workspace.into()), pid: None,
1330            model: None, context_tokens: None, catalogue_path: None,
1331            context_used_pct: None, context_total_tokens: None, context_window_size: None,
1332            claude_session_id: None,
1333            summary: None,
1334            terminal_at: None, gate_status: None,
1335        }
1336    }
1337
1338    #[tokio::test]
1339    async fn poll_pids_leaves_interrupted_sessions_alone() {
1340        use crate::store::Store;
1341
1342        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1343        let mut s = test_session("interrupted-1", "/ws");
1344        s.status = SessionStatus::Interrupted;
1345        s.pid = Some(999_999); // a pid that is almost certainly dead
1346        store.upsert_session(&s).unwrap();
1347        let engine = Engine::new(store.clone());
1348        let poller = Poller::new(engine);
1349
1350        poller.poll_pids().await;
1351
1352        let after = store.get_session("interrupted-1").unwrap().unwrap();
1353        assert!(
1354            matches!(after.status, SessionStatus::Interrupted),
1355            "poll_pids must not re-terminate an Interrupted session just because its stale pid is dead",
1356        );
1357    }
1358
1359    /// End-to-end (within-process) proof that the poller closes the gap
1360    /// documented in `lifecycle::usage`: given a workspace whose `claude`
1361    /// transcript directory has usage recorded, `poll_usage` writes the
1362    /// derived cost/context/model back into the store and emits
1363    /// `SessionUpdated` — the exact path the UI's $0.0000 / missing-tokens
1364    /// symptom traces back to when this ingestion doesn't happen.
1365    // The `ENV_TEST_GUARD` mutex is intentionally held across the `.await`
1366    // points below — it serializes access to the process-global
1367    // `NINOX_CLAUDE_PROJECTS_DIR` env var against other tests (in this file
1368    // and in `lifecycle::usage`) for this single-threaded `#[tokio::test]`,
1369    // and must stay held for the env var's entire lifetime, not just around
1370    // the sync portions.
1371    #[allow(clippy::await_holding_lock)]
1372    #[tokio::test]
1373    async fn poll_usage_ingests_transcript_into_store_and_emits_update() {
1374        use crate::{lifecycle::usage::{claude_project_slug, ENV_TEST_GUARD}, store::Store};
1375        use std::io::Write;
1376
1377        let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1378        let projects_dir = tempfile::tempdir().unwrap();
1379        let workspace = "/tmp/poller-usage-probe-workspace";
1380        let project_dir = projects_dir.path().join(claude_project_slug(workspace));
1381        std::fs::create_dir_all(&project_dir).unwrap();
1382        let mut f = std::fs::File::create(project_dir.join("s.jsonl")).unwrap();
1383        writeln!(
1384            f,
1385            r#"{{"type":"assistant","timestamp":"2026-07-05T13:00:00.000Z","message":{{"model":"claude-fable-5","usage":{{"input_tokens":2,"output_tokens":300,"cache_creation_input_tokens":500,"cache_read_input_tokens":45000}}}}}}"#
1386        ).unwrap();
1387        drop(f);
1388
1389        let prior = std::env::var("NINOX_CLAUDE_PROJECTS_DIR").ok();
1390        std::env::set_var("NINOX_CLAUDE_PROJECTS_DIR", projects_dir.path());
1391
1392        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1393        store.upsert_session(&test_session("s1", workspace)).unwrap();
1394        let engine = Engine::new(store.clone());
1395        let mut rx = engine.subscribe();
1396        let poller = Poller::new(engine);
1397
1398        poller.poll_usage().await;
1399
1400        match prior {
1401            Some(v) => std::env::set_var("NINOX_CLAUDE_PROJECTS_DIR", v),
1402            None    => std::env::remove_var("NINOX_CLAUDE_PROJECTS_DIR"),
1403        }
1404
1405        let updated = store.get_session("s1").unwrap().unwrap();
1406        assert!(updated.cost_usd > 0.0, "cost_usd should be ingested, not 0.0000");
1407        assert_eq!(updated.context_tokens, Some(2 + 500 + 45000));
1408        assert_eq!(updated.model.as_deref(), Some("claude-fable-5"));
1409
1410        let evt = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
1411            .await
1412            .expect("SessionUpdated should be emitted")
1413            .unwrap();
1414        assert!(matches!(evt, Event::SessionUpdated(s, _fields) if s.id == "s1" && s.cost_usd > 0.0));
1415    }
1416
1417    /// The `ninox statusline` subcommand (a separate short-lived process)
1418    /// writes cost/context fields directly into the store — outside any
1419    /// read-modify-write cycle this poller drives. This proves the diff
1420    /// cache detects that external write and re-broadcasts it, and that an
1421    /// untouched session generates no spurious event.
1422    #[tokio::test]
1423    async fn poll_context_updates_emits_only_for_changed_sessions() {
1424        use crate::store::Store;
1425
1426        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1427        let mut s1 = test_session("s1", "/ws1");
1428        let s2 = test_session("s2", "/ws2");
1429        store.upsert_session(&s1).unwrap();
1430        store.upsert_session(&s2).unwrap();
1431        let engine = Engine::new(store.clone());
1432        let mut rx = engine.subscribe();
1433        let poller = Poller::new(engine);
1434
1435        // First tick establishes the baseline — nothing to diff against yet,
1436        // so it must not emit for sessions that already exist with no prior
1437        // cached state.
1438        poller.poll_context_updates().await;
1439        let baseline_events = drain_events(&mut rx);
1440        assert!(baseline_events.is_empty(), "no prior cached state means no change to report");
1441
1442        // Simulate the statusline hook writing directly into the store for s1 only.
1443        s1.context_used_pct = Some(42.0);
1444        s1.cost_usd = 3.5;
1445        store.upsert_session(&s1).unwrap();
1446
1447        poller.poll_context_updates().await;
1448        let events = drain_events(&mut rx);
1449        assert_eq!(events.len(), 1, "only the changed session should emit");
1450        assert!(matches!(
1451            &events[0],
1452            Event::SessionUpdated(s, _fields) if s.id == "s1" && s.context_used_pct == Some(42.0) && s.cost_usd == 3.5
1453        ));
1454
1455        // A third tick with no further changes emits nothing.
1456        poller.poll_context_updates().await;
1457        assert!(drain_events(&mut rx).is_empty());
1458    }
1459
1460    /// Drain every event currently buffered on the receiver.
1461    fn drain_events(rx: &mut tokio::sync::broadcast::Receiver<Event>) -> Vec<Event> {
1462        let mut events = Vec::new();
1463        while let Ok(e) = rx.try_recv() {
1464            events.push(e);
1465        }
1466        events
1467    }
1468
1469    /// A worker that opened three PRs: the first becomes the session's
1470    /// tracked PR, every later one is recorded in the store and raised as an
1471    /// ExtraPr notification — and only once, however often the poller ticks.
1472    #[tokio::test]
1473    async fn metadata_sync_adopts_first_pr_and_flags_every_extra_once() {
1474        use crate::store::Store;
1475
1476        let sessions_dir = tempfile::tempdir().unwrap();
1477        let meta = serde_json::json!({
1478            "agentReportedPrNumber": "44",
1479            "agentReportedPrUrl": "https://github.com/org/repo/pull/44",
1480            "agentReportedPrs": [
1481                {"number": "42", "url": "https://github.com/org/repo/pull/42"},
1482                {"number": "43", "url": "https://github.com/org/repo/pull/43"},
1483                {"number": "44", "url": "https://github.com/org/repo/pull/44"},
1484            ],
1485        });
1486        std::fs::write(
1487            sessions_dir.path().join("s1.json"),
1488            serde_json::to_string(&meta).unwrap(),
1489        ).unwrap();
1490
1491        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1492        store.upsert_session(&test_session("s1", "/ws")).unwrap();
1493        let engine = Engine::new(store.clone());
1494        let mut rx = engine.subscribe();
1495        let poller = Poller::new(engine);
1496
1497        poller.sync_sessions_metadata(sessions_dir.path()).await;
1498
1499        let session = store.get_session("s1").unwrap().unwrap();
1500        assert_eq!(session.pr_number, Some(42), "first PR is the canonical one");
1501        assert!(matches!(session.status, SessionStatus::PrOpen));
1502        assert!(store.get_pr(43).unwrap().is_some(), "extra PR #43 recorded");
1503        assert!(store.get_pr(44).unwrap().is_some(), "extra PR #44 recorded");
1504        assert_eq!(
1505            store.get_pr(43).unwrap().unwrap().url,
1506            "https://github.com/org/repo/pull/43",
1507        );
1508
1509        let events = drain_events(&mut rx);
1510        let extra_notifs: Vec<_> = events.iter().filter(|e| matches!(
1511            e, Event::Notification(n) if n.kind == crate::types::NotificationKind::ExtraPr
1512        )).collect();
1513        assert_eq!(extra_notifs.len(), 2, "one ExtraPr notification per extra PR");
1514
1515        // A notification alone is a one-off toast — the UI must also learn
1516        // about the extra PR itself so it can stay visible on the Pull
1517        // Requests ledger, not just flash a bell icon and vanish.
1518        let mut extra_pr_numbers: Vec<u64> = events.iter().filter_map(|e| match e {
1519            Event::ExtraPrDetected(pr) => Some(pr.number),
1520            _ => None,
1521        }).collect();
1522        extra_pr_numbers.sort();
1523        assert_eq!(
1524            extra_pr_numbers, vec![43, 44],
1525            "ExtraPrDetected must fire for every extra PR, not just the notification",
1526        );
1527        let pr_43 = events.iter().find_map(|e| match e {
1528            Event::ExtraPrDetected(pr) if pr.number == 43 => Some(pr),
1529            _ => None,
1530        }).expect("ExtraPrDetected for #43");
1531        assert_eq!(pr_43.session_id, "s1", "the ledger entry must point back at the owning session");
1532
1533        // Second tick: nothing new — no duplicate notifications or re-detections.
1534        poller.sync_sessions_metadata(sessions_dir.path()).await;
1535        let events = drain_events(&mut rx);
1536        assert!(
1537            !events.iter().any(|e| matches!(e, Event::ExtraPrDetected(_))),
1538            "an extra PR already recorded in the store must not be re-detected on every tick",
1539        );
1540        assert!(
1541            !events.iter().any(|e| matches!(e, Event::Notification(_))),
1542            "extra PRs must not be re-notified on every tick",
1543        );
1544    }
1545
1546    /// A single reported PR (the normal case) adopts it with no extra-PR
1547    /// noise — the pre-existing first-PR-detection behavior.
1548    #[tokio::test]
1549    async fn metadata_sync_single_pr_has_no_extra_notifications() {
1550        use crate::store::Store;
1551
1552        let sessions_dir = tempfile::tempdir().unwrap();
1553        let meta = serde_json::json!({
1554            "agentReportedPrNumber": "5",
1555            "agentReportedPrUrl": "https://github.com/org/repo/pull/5",
1556        });
1557        std::fs::write(
1558            sessions_dir.path().join("s1.json"),
1559            serde_json::to_string(&meta).unwrap(),
1560        ).unwrap();
1561
1562        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1563        store.upsert_session(&test_session("s1", "/ws")).unwrap();
1564        let engine = Engine::new(store.clone());
1565        let mut rx = engine.subscribe();
1566        let poller = Poller::new(engine);
1567
1568        poller.sync_sessions_metadata(sessions_dir.path()).await;
1569
1570        let session = store.get_session("s1").unwrap().unwrap();
1571        assert_eq!(session.pr_number, Some(5));
1572        assert!(matches!(session.status, SessionStatus::PrOpen));
1573        let events = drain_events(&mut rx);
1574        assert!(!events.iter().any(|e| matches!(e, Event::Notification(_))));
1575    }
1576
1577    /// Work requests recorded by `ninox request-work` surface exactly one
1578    /// WorkRequested notification each, then are marked delivered.
1579    #[tokio::test]
1580    async fn metadata_sync_delivers_work_requests_exactly_once() {
1581        use crate::store::Store;
1582
1583        let sessions_dir = tempfile::tempdir().unwrap();
1584        hooks::append_work_request(sessions_dir.path(), "s1", "Migrate the config loader").unwrap();
1585
1586        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1587        store.upsert_session(&test_session("s1", "/ws")).unwrap();
1588        let engine = Engine::new(store.clone());
1589        let mut rx = engine.subscribe();
1590        let poller = Poller::new(engine);
1591
1592        poller.sync_sessions_metadata(sessions_dir.path()).await;
1593
1594        let events = drain_events(&mut rx);
1595        let notif = events.iter().find_map(|e| match e {
1596            Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkRequested => Some(n),
1597            _ => None,
1598        }).expect("WorkRequested notification emitted");
1599        assert!(notif.body.contains("Migrate the config loader"));
1600        assert_eq!(notif.session_id.as_deref(), Some("s1"));
1601
1602        assert!(
1603            hooks::read_pending_work_requests(sessions_dir.path(), "s1").unwrap().is_empty(),
1604            "delivered requests must leave the pending set",
1605        );
1606
1607        poller.sync_sessions_metadata(sessions_dir.path()).await;
1608        let events = drain_events(&mut rx);
1609        assert!(
1610            !events.iter().any(|e| matches!(e, Event::Notification(_))),
1611            "delivered work requests must not fire again",
1612        );
1613    }
1614
1615    /// A worker can request work and exit before the next tick — the request
1616    /// must still reach the orchestrator, not die with the session.
1617    #[tokio::test]
1618    async fn metadata_sync_delivers_work_requests_from_terminated_sessions() {
1619        use crate::store::Store;
1620
1621        let sessions_dir = tempfile::tempdir().unwrap();
1622        hooks::append_work_request(sessions_dir.path(), "s1", "Follow-up refactor").unwrap();
1623
1624        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1625        let mut session = test_session("s1", "/ws");
1626        session.status = SessionStatus::Terminated;
1627        store.upsert_session(&session).unwrap();
1628        let engine = Engine::new(store.clone());
1629        let mut rx = engine.subscribe();
1630        let poller = Poller::new(engine);
1631
1632        poller.sync_sessions_metadata(sessions_dir.path()).await;
1633
1634        let events = drain_events(&mut rx);
1635        assert!(
1636            events.iter().any(|e| matches!(
1637                e, Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkRequested
1638            )),
1639            "work requests outlive their session",
1640        );
1641    }
1642
1643    /// The ledger row for an extra PR is best-effort at notification time (a
1644    /// busy store must not kill the alert) — but it must self-heal on later
1645    /// ticks rather than be lost forever, and healing must not re-notify.
1646    #[tokio::test]
1647    async fn extra_pr_ledger_row_backfills_after_notification_without_renotifying() {
1648        use crate::store::Store;
1649
1650        let sessions_dir = tempfile::tempdir().unwrap();
1651        let meta = serde_json::json!({
1652            "agentReportedPrs": [
1653                {"number": "7", "url": "https://github.com/org/repo/pull/7"},
1654                {"number": "9", "url": "https://github.com/org/repo/pull/9"},
1655            ],
1656        });
1657        std::fs::write(
1658            sessions_dir.path().join("s1.json"),
1659            serde_json::to_string(&meta).unwrap(),
1660        ).unwrap();
1661        // Simulate "notified previously, but the row write failed that tick".
1662        hooks::mark_extra_prs_notified(sessions_dir.path(), "s1", &[9]).unwrap();
1663
1664        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1665        store.upsert_session(&test_session("s1", "/ws")).unwrap();
1666        let engine = Engine::new(store.clone());
1667        let mut rx = engine.subscribe();
1668        let poller = Poller::new(engine);
1669
1670        poller.sync_sessions_metadata(sessions_dir.path()).await;
1671
1672        assert!(
1673            store.get_pr(9).unwrap().is_some(),
1674            "already-notified extra PR must still get its ledger row backfilled",
1675        );
1676        let events = drain_events(&mut rx);
1677        assert!(
1678            !events.iter().any(|e| matches!(
1679                e, Event::Notification(n) if n.kind == crate::types::NotificationKind::ExtraPr
1680            )),
1681            "backfilling the row must not re-notify",
1682        );
1683    }
1684
1685    /// Extra-PR dedup must not be fooled by an unrelated session in another
1686    /// repo already owning the `prs` row for that number (prs.id is the bare
1687    /// PR number, which collides across repos) — and must not steal that row.
1688    #[tokio::test]
1689    async fn extra_pr_detection_survives_cross_repo_pr_number_collision() {
1690        use crate::store::Store;
1691
1692        let sessions_dir = tempfile::tempdir().unwrap();
1693        let meta = serde_json::json!({
1694            "agentReportedPrs": [
1695                {"number": "7", "url": "https://github.com/org/repo-a/pull/7"},
1696                {"number": "9", "url": "https://github.com/org/repo-a/pull/9"},
1697            ],
1698        });
1699        std::fs::write(
1700            sessions_dir.path().join("s1.json"),
1701            serde_json::to_string(&meta).unwrap(),
1702        ).unwrap();
1703
1704        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1705        store.upsert_session(&test_session("s1", "/ws")).unwrap();
1706        // Another repo's session already tracks its own PR #9.
1707        let other = PR {
1708            id: 9, number: 9, title: "other repo's PR".into(),
1709            url: "https://github.com/org/repo-b/pull/9".into(),
1710            body: String::new(), session_id: "other".into(),
1711        };
1712        store.upsert_pr(&other).unwrap();
1713
1714        let engine = Engine::new(store.clone());
1715        let mut rx = engine.subscribe();
1716        let poller = Poller::new(engine);
1717
1718        poller.sync_sessions_metadata(sessions_dir.path()).await;
1719
1720        let events = drain_events(&mut rx);
1721        assert!(
1722            events.iter().any(|e| matches!(
1723                e, Event::Notification(n) if n.kind == crate::types::NotificationKind::ExtraPr
1724            )),
1725            "the collision must not suppress the extra-PR alert",
1726        );
1727        let row = store.get_pr(9).unwrap().unwrap();
1728        assert_eq!(row.session_id, "other", "the other repo's row must not be stolen");
1729        assert_eq!(row.url, "https://github.com/org/repo-b/pull/9");
1730        assert!(
1731            !events.iter().any(|e| matches!(e, Event::ExtraPrDetected(pr) if pr.number == 9)),
1732            "must not emit ExtraPrDetected for a row it didn't (and mustn't) write — that \
1733             would let s1 clobber the ledger UI's view of another session's PR",
1734        );
1735
1736        poller.sync_sessions_metadata(sessions_dir.path()).await;
1737        let events = drain_events(&mut rx);
1738        assert!(
1739            !events.iter().any(|e| matches!(e, Event::Notification(_))),
1740            "dedup must hold across ticks even without a prs row of our own",
1741        );
1742    }
1743
1744    #[tokio::test]
1745    async fn poll_usage_leaves_sessions_without_workspace_or_usage_untouched() {
1746        use crate::store::Store;
1747
1748        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1749        let mut no_ws = test_session("no-ws", "/does/not/matter");
1750        no_ws.workspace_path = None;
1751        store.upsert_session(&no_ws).unwrap();
1752        let engine = Engine::new(store.clone());
1753        let poller = Poller::new(engine);
1754
1755        poller.poll_usage().await;
1756
1757        let unchanged = store.get_session("no-ws").unwrap().unwrap();
1758        assert_eq!(unchanged.cost_usd, 0.0);
1759        assert_eq!(unchanged.context_tokens, None);
1760    }
1761
1762    // ── GithubApi fake — drives poll_github/poll_pr_reconciliation with no
1763    // network access, so the dual-remote fallback and reconciliation logic
1764    // can be exercised deterministically. ───────────────────────────────────
1765
1766    #[derive(Default)]
1767    struct FakeGithub {
1768        /// Keyed by (owner, repo, pr_number) — PR numbers are per-repo, so a
1769        /// fake that ignored the number couldn't catch a cross-repo number
1770        /// collision bug.
1771        pr_status_ok:   std::sync::Mutex<HashMap<(String, String, u64), crate::github::PrStatus>>,
1772        branch_matches: std::sync::Mutex<HashMap<(String, String, String), crate::github::PrRef>>,
1773        /// (owner, repo, pr_number) triples `get_pr_status` was actually called with, in order.
1774        calls: std::sync::Mutex<Vec<(String, String, u64)>>,
1775        /// When set, `get_review_threads` upserts this row into the store
1776        /// before returning — simulating the external `ninox statusline`
1777        /// process landing a cost/context write mid-poll, after
1778        /// `poll_github` took its `list_sessions()` snapshot but before its
1779        /// status/gate write.
1780        mid_review_upsert: std::sync::Mutex<Option<(std::sync::Arc<crate::store::Store>, crate::types::Session)>>,
1781        /// Same as `mid_review_upsert`, but fired from `get_pr_status` —
1782        /// i.e. before the *self-heal* write rather than the status/gate
1783        /// write, which needs its own mid-poll statusline simulation.
1784        mid_pr_status_upsert: std::sync::Mutex<Option<(std::sync::Arc<crate::store::Store>, crate::types::Session)>>,
1785        /// When set, `get_review_threads` deletes this session id from the
1786        /// store before returning — simulating the session being removed
1787        /// mid-poll, after the snapshot but before the status/gate write.
1788        mid_review_delete: std::sync::Mutex<Option<(std::sync::Arc<crate::store::Store>, String)>>,
1789        /// Same statusline simulation as `mid_review_upsert`, but fired from
1790        /// `find_open_pr_for_branch` — the await inside
1791        /// `poll_pr_reconciliation`'s adoption loop.
1792        mid_branch_lookup_upsert: std::sync::Mutex<Option<(std::sync::Arc<crate::store::Store>, crate::types::Session)>>,
1793        /// Review threads (reviews + inline diff comments) `get_review_threads`
1794        /// returns — empty by default, same as before comment tests existed.
1795        review_threads: std::sync::Mutex<Vec<crate::github::ReviewThread>>,
1796        /// Issue-level conversation comments `get_issue_comments` returns.
1797        issue_comments: std::sync::Mutex<Vec<crate::types::Comment>>,
1798    }
1799
1800    #[async_trait::async_trait]
1801    impl crate::github::GithubApi for FakeGithub {
1802        async fn get_pr_status(&self, owner: &str, repo: &str, pr_number: u64) -> anyhow::Result<crate::github::PrStatus> {
1803            self.calls.lock().unwrap().push((owner.to_string(), repo.to_string(), pr_number));
1804            if let Some((store, row)) = self.mid_pr_status_upsert.lock().unwrap().take() {
1805                store.upsert_session(&row).unwrap();
1806            }
1807            self.pr_status_ok.lock().unwrap()
1808                .get(&(owner.to_string(), repo.to_string(), pr_number))
1809                .cloned()
1810                .ok_or_else(|| anyhow::anyhow!("404 for {owner}/{repo}#{pr_number}"))
1811        }
1812        async fn get_ci_checks(&self, _owner: &str, _repo: &str, _head_sha: &str) -> anyhow::Result<Vec<CheckRun>> {
1813            Ok(vec![])
1814        }
1815        async fn get_review_threads(&self, _owner: &str, _repo: &str, _pr_number: u64) -> anyhow::Result<Vec<crate::github::ReviewThread>> {
1816            if let Some((store, row)) = self.mid_review_upsert.lock().unwrap().take() {
1817                store.upsert_session(&row).unwrap();
1818            }
1819            if let Some((store, id)) = self.mid_review_delete.lock().unwrap().take() {
1820                store.delete_session(&id).unwrap();
1821            }
1822            Ok(self.review_threads.lock().unwrap().clone())
1823        }
1824        async fn get_issue_comments(&self, _owner: &str, _repo: &str, _pr_number: u64) -> anyhow::Result<Vec<crate::types::Comment>> {
1825            Ok(self.issue_comments.lock().unwrap().clone())
1826        }
1827        async fn find_open_pr_for_branch(&self, owner: &str, repo: &str, branch: &str) -> anyhow::Result<Option<crate::github::PrRef>> {
1828            if let Some((store, row)) = self.mid_branch_lookup_upsert.lock().unwrap().take() {
1829                store.upsert_session(&row).unwrap();
1830            }
1831            Ok(self.branch_matches.lock().unwrap()
1832                .get(&(owner.to_string(), repo.to_string(), branch.to_string()))
1833                .cloned())
1834        }
1835    }
1836
1837    fn init_git_repo(branch: &str, remotes: &[(&str, &str)]) -> tempfile::TempDir {
1838        let dir = tempfile::tempdir().unwrap();
1839        let workspace = dir.path().to_string_lossy().to_string();
1840        let run = |args: &[&str]| {
1841            let status = std::process::Command::new("git")
1842                .args(["-C", &workspace]).args(args).status().unwrap();
1843            assert!(status.success(), "git {args:?} failed");
1844        };
1845        run(&["init", "-q"]);
1846        run(&["config", "user.email", "test@example.com"]);
1847        run(&["config", "user.name", "Test"]);
1848        run(&["commit", "--allow-empty", "-q", "-m", "init"]);
1849        run(&["checkout", "-q", "-b", branch]);
1850        for (name, url) in remotes {
1851            run(&["remote", "add", name, url]);
1852        }
1853        dir
1854    }
1855
1856    fn github_engine(store: std::sync::Arc<crate::store::Store>, gh: std::sync::Arc<FakeGithub>) -> std::sync::Arc<Engine> {
1857        Engine::new_with_github_api(store, gh as std::sync::Arc<dyn crate::github::GithubApi>)
1858    }
1859
1860    /// The core acceptance criterion: a PR opened without the wrapped `gh pr
1861    /// create` ever running (no metadata file at all) must still end up
1862    /// adopted, purely from an active branch lookup against a configured
1863    /// remote.
1864    #[tokio::test]
1865    async fn poll_pr_reconciliation_adopts_pr_found_without_wrapper_metadata() {
1866        use crate::store::Store;
1867
1868        let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
1869        let workspace = repo_dir.path().to_string_lossy().to_string();
1870
1871        let fake = std::sync::Arc::new(FakeGithub::default());
1872        fake.branch_matches.lock().unwrap().insert(
1873            ("Owner".to_string(), "repo".to_string(), "worker-branch".to_string()),
1874            crate::github::PrRef { number: 77, url: "https://github.com/Owner/repo/pull/77".into() },
1875        );
1876
1877        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1878        store.upsert_session(&test_session("s1", &workspace)).unwrap();
1879        let engine = github_engine(store.clone(), fake);
1880        let poller = Poller::new(engine);
1881
1882        poller.poll_pr_reconciliation().await;
1883
1884        let session = store.get_session("s1").unwrap().unwrap();
1885        assert_eq!(session.pr_number, Some(77), "no wrapper metadata was ever written — reconciliation must still find it");
1886        assert!(matches!(session.status, SessionStatus::PrOpen));
1887        assert_eq!(session.repo, "Owner/repo");
1888    }
1889
1890    /// The same stale-upsert race `poll_github`'s writes were fixed for:
1891    /// reconciliation's adoption write starts from the tick-start snapshot,
1892    /// but `find_open_pr_for_branch` is a network await during which the
1893    /// statusline process can land cost/context. The adoption must go
1894    /// through the live row.
1895    #[tokio::test]
1896    async fn poll_pr_reconciliation_does_not_revert_mid_poll_statusline_fields() {
1897        use crate::store::Store;
1898
1899        let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
1900        let workspace = repo_dir.path().to_string_lossy().to_string();
1901
1902        let fake = std::sync::Arc::new(FakeGithub::default());
1903        fake.branch_matches.lock().unwrap().insert(
1904            ("Owner".to_string(), "repo".to_string(), "worker-branch".to_string()),
1905            crate::github::PrRef { number: 77, url: "https://github.com/Owner/repo/pull/77".into() },
1906        );
1907
1908        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1909        let session = test_session("s1", &workspace);
1910        store.upsert_session(&session).unwrap();
1911
1912        let mut mid_poll = session.clone();
1913        mid_poll.cost_usd = 7.25;
1914        mid_poll.context_used_pct = Some(55.0);
1915        *fake.mid_branch_lookup_upsert.lock().unwrap() = Some((store.clone(), mid_poll));
1916
1917        let engine = github_engine(store.clone(), fake);
1918        let poller = Poller::new(engine);
1919
1920        poller.poll_pr_reconciliation().await;
1921
1922        let updated = store.get_session("s1").unwrap().unwrap();
1923        assert_eq!(updated.pr_number, Some(77), "the adoption write itself must still land");
1924        assert!(matches!(updated.status, SessionStatus::PrOpen));
1925        assert_eq!(
1926            updated.cost_usd, 7.25,
1927            "adoption must not revert cost_usd written mid-poll by the statusline process",
1928        );
1929        assert_eq!(updated.context_used_pct, Some(55.0), "mid-poll context fields must survive too");
1930    }
1931
1932    #[tokio::test]
1933    async fn poll_pr_reconciliation_leaves_sessions_with_no_matching_pr_untouched() {
1934        use crate::store::Store;
1935
1936        let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
1937        let workspace = repo_dir.path().to_string_lossy().to_string();
1938
1939        let fake = std::sync::Arc::new(FakeGithub::default()); // no branch matches configured
1940
1941        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1942        store.upsert_session(&test_session("s1", &workspace)).unwrap();
1943        let engine = github_engine(store.clone(), fake);
1944        let poller = Poller::new(engine);
1945
1946        poller.poll_pr_reconciliation().await;
1947
1948        let session = store.get_session("s1").unwrap().unwrap();
1949        assert_eq!(session.pr_number, None);
1950    }
1951
1952    /// The dual-remote gap from the bug report: the session's recorded repo
1953    /// (`origin`) 404s, but the PR actually lives against a second
1954    /// configured remote (an internal mirror) — `poll_github` must fall
1955    /// back to it instead of silently stalling, and self-heal `session.repo`
1956    /// (and `session.pr_number`, since PR numbers are per-repo) so later
1957    /// ticks go straight there. The mirror's real PR is deliberately given a
1958    /// *different* number (99, not the tracked 50) — a repo whose branch
1959    /// match is only found by matching the branch, not by coincidentally
1960    /// reusing the same numeric `pr_number`.
1961    #[tokio::test]
1962    async fn poll_github_falls_back_to_other_remote_when_recorded_repo_404s() {
1963        use crate::store::Store;
1964
1965        let repo_dir = init_git_repo("worker-branch", &[
1966            ("origin", "https://github.com/OwnerA/repoA.git"),
1967            ("mirror", "https://github.com/OwnerB/repoB.git"),
1968        ]);
1969        let workspace = repo_dir.path().to_string_lossy().to_string();
1970
1971        let fake = std::sync::Arc::new(FakeGithub::default());
1972        fake.branch_matches.lock().unwrap().insert(
1973            ("OwnerB".to_string(), "repoB".to_string(), "worker-branch".to_string()),
1974            crate::github::PrRef { number: 99, url: "https://github.com/OwnerB/repoB/pull/99".into() },
1975        );
1976        fake.pr_status_ok.lock().unwrap().insert(
1977            ("OwnerB".to_string(), "repoB".to_string(), 99),
1978            crate::github::PrStatus {
1979                merged: false, state: "open".into(), mergeable: Some(true),
1980                title: "t".into(), number: 99, head_sha: "abc".into(),
1981            },
1982        );
1983        // OwnerA/repoA#50 has no entry — get_pr_status errors, simulating a 404.
1984
1985        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
1986        let mut session = test_session("s1", &workspace);
1987        session.repo = "OwnerA/repoA".into();
1988        session.pr_number = Some(50);
1989        store.upsert_session(&session).unwrap();
1990
1991        let engine = github_engine(store.clone(), fake.clone());
1992        let poller = Poller::new(engine);
1993
1994        poller.poll_github().await;
1995
1996        let updated = store.get_session("s1").unwrap().unwrap();
1997        assert_eq!(updated.repo, "OwnerB/repoB", "session.repo must self-heal to the remote that actually has the PR");
1998        assert_eq!(updated.pr_number, Some(99), "must adopt the mirror's own PR number, not reuse the tracked repo's number");
1999
2000        let calls = fake.calls.lock().unwrap().clone();
2001        assert_eq!(
2002            calls,
2003            vec![("OwnerA".to_string(), "repoA".to_string(), 50), ("OwnerB".to_string(), "repoB".to_string(), 99)],
2004            "the repo on record must be tried first (by its own number), the mirror only as a branch-matched fallback",
2005        );
2006    }
2007
2008    /// The bug this guards against: PR numbers are a per-repository
2009    /// sequence with no cross-repo relationship. If the recorded repo 404s,
2010    /// a *different*, unrelated repo can easily have some PR at the exact
2011    /// same number purely by coincidence. Blindly retrying the tracked
2012    /// number against that repo would silently adopt the wrong PR. Since
2013    /// that unrelated PR's head branch doesn't match this session's branch,
2014    /// the branch-matching fallback must not adopt it — even though a
2015    /// `get_pr_status` for that same number would have "succeeded".
2016    #[tokio::test]
2017    async fn poll_github_does_not_adopt_an_unrelated_pr_that_shares_the_tracked_number() {
2018        use crate::store::Store;
2019
2020        let repo_dir = init_git_repo("worker-branch", &[
2021            ("origin", "https://github.com/OwnerA/repoA.git"),
2022            ("mirror", "https://github.com/OwnerB/repoB.git"),
2023        ]);
2024        let workspace = repo_dir.path().to_string_lossy().to_string();
2025
2026        let fake = std::sync::Arc::new(FakeGithub::default());
2027        // OwnerB/repoB happens to have *some* PR #50 too, but it's unrelated
2028        // — its branch doesn't match, so no `branch_matches` entry for it.
2029        fake.pr_status_ok.lock().unwrap().insert(
2030            ("OwnerB".to_string(), "repoB".to_string(), 50),
2031            crate::github::PrStatus {
2032                merged: false, state: "open".into(), mergeable: Some(true),
2033                title: "someone else's unrelated PR".into(), number: 50, head_sha: "zzz".into(),
2034            },
2035        );
2036        // OwnerA/repoA#50 has no entry — get_pr_status errors, simulating a 404.
2037
2038        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2039        let mut session = test_session("s1", &workspace);
2040        session.repo = "OwnerA/repoA".into();
2041        session.pr_number = Some(50);
2042        store.upsert_session(&session).unwrap();
2043
2044        let engine = github_engine(store.clone(), fake.clone());
2045        let poller = Poller::new(engine);
2046
2047        poller.poll_github().await;
2048
2049        let updated = store.get_session("s1").unwrap().unwrap();
2050        assert_eq!(updated.repo, "OwnerA/repoA", "must not adopt the mirror just because it happens to have a same-numbered PR");
2051        assert_eq!(updated.pr_number, Some(50));
2052
2053        let calls = fake.calls.lock().unwrap().clone();
2054        assert!(
2055            !calls.contains(&("OwnerB".to_string(), "repoB".to_string(), 50)),
2056            "must never retry the tracked number against another repo's get_pr_status: {calls:?}",
2057        );
2058    }
2059
2060    /// The flicker bug: once `poll_github` discovers a PR, `session.pr_id`
2061    /// must be persisted to the store — not just carried on the in-memory
2062    /// `Event::PrOpened` the UI happens to receive. Every other poller
2063    /// (`poll_usage`, `poll_context_updates`, `sync_sessions_metadata`) does
2064    /// its own read-modify-write cycle against `list_sessions()` and
2065    /// re-emits a full `Event::SessionUpdated` snapshot; if `pr_id` was
2066    /// never written back, that snapshot carries `pr_id: None` and stomps
2067    /// the UI's in-memory `pr_id` back to `None` on the very next unrelated
2068    /// tick (e.g. a cost/token update while the agent is still generating
2069    /// text) — which is exactly what made the session detail view's PR card
2070    /// flicker between "No PR yet" and the real card.
2071    #[tokio::test]
2072    async fn poll_github_persists_pr_id_so_later_polls_dont_regress_it() {
2073        use crate::store::Store;
2074
2075        let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
2076        let workspace = repo_dir.path().to_string_lossy().to_string();
2077
2078        let fake = std::sync::Arc::new(FakeGithub::default());
2079        fake.pr_status_ok.lock().unwrap().insert(
2080            ("Owner".to_string(), "repo".to_string(), 50),
2081            crate::github::PrStatus {
2082                merged: false, state: "open".into(), mergeable: Some(true),
2083                title: "t".into(), number: 50, head_sha: "abc".into(),
2084            },
2085        );
2086
2087        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2088        let mut session = test_session("s1", &workspace);
2089        session.repo = "Owner/repo".into();
2090        session.pr_number = Some(50);
2091        store.upsert_session(&session).unwrap();
2092
2093        let engine = github_engine(store.clone(), fake);
2094        let poller = Poller::new(engine);
2095
2096        poller.poll_github().await;
2097
2098        let updated = store.get_session("s1").unwrap().unwrap();
2099        assert_eq!(
2100            updated.pr_id, Some(50),
2101            "pr_id discovered by poll_github must be persisted to the store, or a later \
2102             read-modify-write by any other poller will re-broadcast pr_id: None and flicker the UI",
2103        );
2104    }
2105
2106    /// End-to-end regression for the `compute_new_gate` terminal guard: a
2107    /// `Terminated` session with an unresolved PR is still polled by
2108    /// `poll_github` (see the comment on its status guard, purely to catch a
2109    /// late merge), but its already-set `gate_status` must survive the tick
2110    /// unchanged even though the live CI/mergeable signals now disagree with
2111    /// it — `derive_session_status` already has its own terminal guard for
2112    /// `status`; this proves `gate_status` gets the same treatment.
2113    #[tokio::test]
2114    async fn poll_github_does_not_recompute_gate_for_terminated_session_with_unresolved_pr() {
2115        use crate::store::Store;
2116
2117        let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
2118        let workspace = repo_dir.path().to_string_lossy().to_string();
2119
2120        let fake = std::sync::Arc::new(FakeGithub::default());
2121        fake.pr_status_ok.lock().unwrap().insert(
2122            ("Owner".to_string(), "repo".to_string(), 50),
2123            crate::github::PrStatus {
2124                // Not merged — the PR is still unresolved, which is exactly
2125                // why `poll_github` doesn't skip a `Terminated` session. All
2126                // live signals (mergeable, and CI/review from FakeGithub's
2127                // empty checks/threads) are as green as possible, directly
2128                // contradicting the stale stored gate below.
2129                merged: false, state: "open".into(), mergeable: Some(true),
2130                title: "t".into(), number: 50, head_sha: "abc".into(),
2131            },
2132        );
2133
2134        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2135        let mut session = test_session("s1", &workspace);
2136        session.repo = "Owner/repo".into();
2137        session.pr_number = Some(50);
2138        session.status = SessionStatus::Terminated;
2139        let stale_gate = GateStatus {
2140            ci: GateCheck::Failing, review: GateCheck::Failing,
2141            mergeable: GateCheck::Failing, since: 111,
2142        };
2143        session.gate_status = Some(stale_gate.clone());
2144        store.upsert_session(&session).unwrap();
2145
2146        let engine = github_engine(store.clone(), fake);
2147        let poller = Poller::new(engine);
2148
2149        poller.poll_github().await;
2150
2151        let updated = store.get_session("s1").unwrap().unwrap();
2152        assert!(
2153            matches!(updated.status, SessionStatus::Terminated),
2154            "status must stay Terminated (derive_session_status's own terminal guard)",
2155        );
2156        assert_eq!(
2157            updated.gate_status, Some(stale_gate),
2158            "gate_status must be carried forward unchanged for a terminal session, \
2159             even though live CI/mergeable data disagrees with it",
2160        );
2161    }
2162
2163    /// Guards against a narrower version of the same bug: if the discovered
2164    /// PR is *already merged* on the very tick it's found, merge detection
2165    /// short-circuits the rest of the loop iteration and moves the session
2166    /// to `Done` — a status `poll_github` never revisits (see the guard at
2167    /// the top of the loop). `pr_id` must therefore be persisted *before*
2168    /// merge detection runs, not alongside the (skipped) PR-upsert block
2169    /// further down, or a merged-on-discovery session is stuck with a
2170    /// correct `pr_number` but a permanently stale/absent `pr_id` — the
2171    /// exact inconsistency this function otherwise fixes, just for a
2172    /// session that never gets a second chance to self-correct. Also
2173    /// exercises this alongside the dual-remote self-heal path, so
2174    /// `repo`/`pr_number`/`pr_id` are all proven to land in the same write.
2175    #[tokio::test]
2176    async fn poll_github_persists_pr_id_when_the_self_healed_pr_is_already_merged() {
2177        use crate::store::Store;
2178
2179        let repo_dir = init_git_repo("worker-branch", &[
2180            ("origin", "https://github.com/OwnerA/repoA.git"),
2181            ("mirror", "https://github.com/OwnerB/repoB.git"),
2182        ]);
2183        let workspace = repo_dir.path().to_string_lossy().to_string();
2184
2185        let fake = std::sync::Arc::new(FakeGithub::default());
2186        fake.branch_matches.lock().unwrap().insert(
2187            ("OwnerB".to_string(), "repoB".to_string(), "worker-branch".to_string()),
2188            crate::github::PrRef { number: 99, url: "https://github.com/OwnerB/repoB/pull/99".into() },
2189        );
2190        fake.pr_status_ok.lock().unwrap().insert(
2191            ("OwnerB".to_string(), "repoB".to_string(), 99),
2192            crate::github::PrStatus {
2193                merged: true, state: "closed".into(), mergeable: None,
2194                title: "t".into(), number: 99, head_sha: "abc".into(),
2195            },
2196        );
2197        // OwnerA/repoA#50 has no entry — get_pr_status errors, simulating a 404,
2198        // forcing the branch-match fallback to OwnerB/repoB#99.
2199
2200        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2201        let mut session = test_session("s1", &workspace);
2202        session.repo = "OwnerA/repoA".into();
2203        session.pr_number = Some(50);
2204        store.upsert_session(&session).unwrap();
2205
2206        let engine = github_engine(store.clone(), fake);
2207        let poller = Poller::new(engine);
2208
2209        poller.poll_github().await;
2210
2211        let updated = store.get_session("s1").unwrap().unwrap();
2212        assert_eq!(updated.repo, "OwnerB/repoB", "self-heal must still land");
2213        assert_eq!(updated.pr_number, Some(99), "self-heal must still land");
2214        assert_eq!(
2215            updated.pr_id, Some(99),
2216            "pr_id must be persisted even though the self-healed PR is already merged and \
2217             merge detection short-circuits the rest of this loop iteration",
2218        );
2219        assert!(matches!(updated.status, SessionStatus::Done), "merged PR still transitions to Done");
2220    }
2221
2222    /// The stale-upsert race: `poll_github`'s status/gate write starts from
2223    /// the `list_sessions()` snapshot taken at the top of the tick, but the
2224    /// external `ninox statusline` process (see `poll_context_updates`)
2225    /// writes cost/context straight into the same row during the GitHub
2226    /// awaits in between — and `upsert_session` is a full-row last-writer-
2227    /// wins write. Persisting the snapshot would silently revert those
2228    /// fresher fields; the fix re-reads the live row and sets only
2229    /// status/gate on it. The fake's `get_review_threads` (the last await
2230    /// before the write) plays the statusline process here.
2231    #[tokio::test]
2232    async fn poll_github_status_write_does_not_revert_mid_poll_statusline_fields() {
2233        use crate::store::Store;
2234
2235        let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
2236        let workspace = repo_dir.path().to_string_lossy().to_string();
2237
2238        let fake = std::sync::Arc::new(FakeGithub::default());
2239        fake.pr_status_ok.lock().unwrap().insert(
2240            ("Owner".to_string(), "repo".to_string(), 50),
2241            crate::github::PrStatus {
2242                merged: false, state: "open".into(), mergeable: Some(true),
2243                title: "t".into(), number: 50, head_sha: "abc".into(),
2244            },
2245        );
2246
2247        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2248        let mut session = test_session("s1", &workspace);
2249        session.repo = "Owner/repo".into();
2250        session.pr_number = Some(50);
2251        session.pr_id = Some(50); // fully consistent — no self-heal write this tick
2252        store.upsert_session(&session).unwrap();
2253
2254        // The statusline write that lands mid-poll: fresher cost/context.
2255        let mut mid_poll = session.clone();
2256        mid_poll.cost_usd = 7.25;
2257        mid_poll.context_used_pct = Some(55.0);
2258        mid_poll.context_tokens = Some(123);
2259        *fake.mid_review_upsert.lock().unwrap() = Some((store.clone(), mid_poll));
2260
2261        let engine = github_engine(store.clone(), fake);
2262        let poller = Poller::new(engine);
2263
2264        poller.poll_github().await;
2265
2266        let updated = store.get_session("s1").unwrap().unwrap();
2267        assert!(
2268            matches!(updated.status, SessionStatus::Mergeable),
2269            "the status write itself must still land (open PR, all-green signals): {:?}",
2270            updated.status,
2271        );
2272        assert!(updated.gate_status.is_some(), "the gate write must still land");
2273        assert_eq!(
2274            updated.cost_usd, 7.25,
2275            "persisting status/gate must not revert cost_usd written mid-poll by the statusline process",
2276        );
2277        assert_eq!(updated.context_used_pct, Some(55.0), "mid-poll context fields must survive too");
2278        assert_eq!(updated.context_tokens, Some(123));
2279    }
2280
2281    /// Same stale-upsert race as above, one write earlier in the tick: the
2282    /// *self-heal* write (repo/pr_number/pr_id) also starts from the
2283    /// snapshot, and the statusline process can land cost/context during
2284    /// the `get_pr_status` awaits that precede it. The fake's
2285    /// `get_pr_status` plays the statusline process here.
2286    #[tokio::test]
2287    async fn poll_github_self_heal_write_does_not_revert_mid_poll_statusline_fields() {
2288        use crate::store::Store;
2289
2290        let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
2291        let workspace = repo_dir.path().to_string_lossy().to_string();
2292
2293        let fake = std::sync::Arc::new(FakeGithub::default());
2294        fake.pr_status_ok.lock().unwrap().insert(
2295            ("Owner".to_string(), "repo".to_string(), 50),
2296            crate::github::PrStatus {
2297                merged: false, state: "open".into(), mergeable: Some(true),
2298                title: "t".into(), number: 50, head_sha: "abc".into(),
2299            },
2300        );
2301
2302        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2303        let mut session = test_session("s1", &workspace);
2304        session.repo = "Owner/repo".into();
2305        session.pr_number = Some(50);
2306        session.pr_id = None; // inconsistent — triggers the self-heal write
2307        store.upsert_session(&session).unwrap();
2308
2309        let mut mid_poll = session.clone();
2310        mid_poll.cost_usd = 7.25;
2311        mid_poll.context_used_pct = Some(55.0);
2312        *fake.mid_pr_status_upsert.lock().unwrap() = Some((store.clone(), mid_poll));
2313
2314        let engine = github_engine(store.clone(), fake);
2315        let poller = Poller::new(engine);
2316
2317        poller.poll_github().await;
2318
2319        let updated = store.get_session("s1").unwrap().unwrap();
2320        assert_eq!(updated.pr_id, Some(50), "the self-heal write itself must still land");
2321        assert_eq!(
2322            updated.cost_usd, 7.25,
2323            "the self-heal write must not revert cost_usd written mid-poll by the statusline process",
2324        );
2325        assert_eq!(updated.context_used_pct, Some(55.0), "mid-poll context fields must survive too");
2326    }
2327
2328    /// A session deleted between the tick-start snapshot and the
2329    /// status/gate write must stay deleted — falling back to the snapshot
2330    /// there would resurrect the row.
2331    #[tokio::test]
2332    async fn poll_github_status_write_does_not_resurrect_a_row_deleted_mid_poll() {
2333        use crate::store::Store;
2334
2335        let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
2336        let workspace = repo_dir.path().to_string_lossy().to_string();
2337
2338        let fake = std::sync::Arc::new(FakeGithub::default());
2339        fake.pr_status_ok.lock().unwrap().insert(
2340            ("Owner".to_string(), "repo".to_string(), 50),
2341            crate::github::PrStatus {
2342                merged: false, state: "open".into(), mergeable: Some(true),
2343                title: "t".into(), number: 50, head_sha: "abc".into(),
2344            },
2345        );
2346
2347        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2348        let mut session = test_session("s1", &workspace);
2349        session.repo = "Owner/repo".into();
2350        session.pr_number = Some(50);
2351        session.pr_id = Some(50); // fully consistent — no self-heal write this tick
2352        store.upsert_session(&session).unwrap();
2353
2354        *fake.mid_review_delete.lock().unwrap() = Some((store.clone(), "s1".to_string()));
2355
2356        let engine = github_engine(store.clone(), fake);
2357        let poller = Poller::new(engine);
2358
2359        poller.poll_github().await;
2360
2361        assert!(
2362            store.get_session("s1").unwrap().is_none(),
2363            "a session deleted mid-poll must not be re-inserted by the status/gate write",
2364        );
2365    }
2366
2367    /// Per the `SessionFields` contract (see its doc comment), a producer
2368    /// must flag exactly the fields it persisted. The status/gate write
2369    /// persists only `status` and `gate_status` — flagging PR_LINK too (as
2370    /// it once did) would make the receiving `merge_from` copy this tick's
2371    /// snapshot of `pr_number`/`pr_id`/`repo` over values a fresher emitter
2372    /// may have established since.
2373    #[tokio::test]
2374    async fn poll_github_status_gate_emit_flags_exactly_status_and_gate() {
2375        use crate::store::Store;
2376
2377        let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
2378        let workspace = repo_dir.path().to_string_lossy().to_string();
2379
2380        let fake = std::sync::Arc::new(FakeGithub::default());
2381        fake.pr_status_ok.lock().unwrap().insert(
2382            ("Owner".to_string(), "repo".to_string(), 50),
2383            crate::github::PrStatus {
2384                merged: false, state: "open".into(), mergeable: Some(true),
2385                title: "t".into(), number: 50, head_sha: "abc".into(),
2386            },
2387        );
2388
2389        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2390        let mut session = test_session("s1", &workspace);
2391        session.repo = "Owner/repo".into();
2392        session.pr_number = Some(50);
2393        session.pr_id = Some(50); // fully consistent — nothing to self-heal
2394        store.upsert_session(&session).unwrap();
2395
2396        let engine = github_engine(store.clone(), fake);
2397        let mut rx = engine.subscribe();
2398        let poller = Poller::new(engine);
2399
2400        poller.poll_github().await;
2401
2402        let events = drain_events(&mut rx);
2403        let fields: Vec<SessionFields> = events.iter().filter_map(|e| match e {
2404            Event::SessionUpdated(s, f) if s.id == "s1" => Some(*f),
2405            _ => None,
2406        }).collect();
2407        assert_eq!(
2408            fields, vec![SessionFields::STATUS | SessionFields::GATE],
2409            "with nothing to self-heal, the tick's only SessionUpdated is the status/gate \
2410             write, flagged for exactly the two fields it persisted — no PR_LINK",
2411        );
2412    }
2413
2414    /// The self-heal write is the only thing that persists corrected
2415    /// `repo`/`pr_number`/`pr_id`, so it must announce them itself — the
2416    /// status/gate emit no longer over-flags PR_LINK on its behalf (the
2417    /// former, accidental delivery channel). The PR_LINK-only flag restricts
2418    /// the receiving `merge_from` to exactly the fields the self-heal wrote.
2419    #[tokio::test]
2420    async fn poll_github_self_heal_emits_pr_link_flagged_update() {
2421        use crate::store::Store;
2422
2423        let repo_dir = init_git_repo("worker-branch", &[
2424            ("origin", "https://github.com/OwnerA/repoA.git"),
2425            ("mirror", "https://github.com/OwnerB/repoB.git"),
2426        ]);
2427        let workspace = repo_dir.path().to_string_lossy().to_string();
2428
2429        let fake = std::sync::Arc::new(FakeGithub::default());
2430        fake.branch_matches.lock().unwrap().insert(
2431            ("OwnerB".to_string(), "repoB".to_string(), "worker-branch".to_string()),
2432            crate::github::PrRef { number: 99, url: "https://github.com/OwnerB/repoB/pull/99".into() },
2433        );
2434        fake.pr_status_ok.lock().unwrap().insert(
2435            ("OwnerB".to_string(), "repoB".to_string(), 99),
2436            crate::github::PrStatus {
2437                merged: false, state: "open".into(), mergeable: Some(true),
2438                title: "t".into(), number: 99, head_sha: "abc".into(),
2439            },
2440        );
2441        // OwnerA/repoA#50 has no entry — get_pr_status errors, simulating a
2442        // 404, forcing the branch-match fallback (and thus the self-heal).
2443
2444        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2445        let mut session = test_session("s1", &workspace);
2446        session.repo = "OwnerA/repoA".into();
2447        session.pr_number = Some(50);
2448        store.upsert_session(&session).unwrap();
2449
2450        let engine = github_engine(store.clone(), fake);
2451        let mut rx = engine.subscribe();
2452        let poller = Poller::new(engine);
2453
2454        poller.poll_github().await;
2455
2456        let events = drain_events(&mut rx);
2457        let healed = events.iter().find_map(|e| match e {
2458            Event::SessionUpdated(s, f) if *f == SessionFields::PR_LINK => Some(s.clone()),
2459            _ => None,
2460        }).expect(
2461            "self-heal must emit its own PR_LINK-flagged SessionUpdated — it's the only \
2462             channel by which the corrected pr fields reach the GUI",
2463        );
2464        assert_eq!(healed.repo, "OwnerB/repoB");
2465        assert_eq!(healed.pr_number, Some(99));
2466        assert_eq!(healed.pr_id, Some(99));
2467
2468        for e in &events {
2469            if let Event::SessionUpdated(_, f) = e {
2470                if f.contains(SessionFields::STATUS) {
2471                    assert!(
2472                        !f.contains(SessionFields::PR_LINK),
2473                        "the status/gate emit must not over-flag PR_LINK on the self-heal's behalf",
2474                    );
2475                }
2476            }
2477        }
2478    }
2479
2480    /// When every configured remote fails, that must be visible — not just
2481    /// a `tracing::warn!` — but deduped so it doesn't spam every tick, and
2482    /// re-armed once the session recovers and then fails again.
2483    #[tokio::test]
2484    async fn poll_github_notifies_once_when_every_remote_fails_and_rearms_after_recovery() {
2485        use crate::store::Store;
2486
2487        let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/OwnerA/repoA.git")]);
2488        let workspace = repo_dir.path().to_string_lossy().to_string();
2489
2490        let fake = std::sync::Arc::new(FakeGithub::default()); // always 404s
2491
2492        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2493        let mut session = test_session("s1", &workspace);
2494        session.repo = "OwnerA/repoA".into();
2495        session.pr_number = Some(50);
2496        store.upsert_session(&session).unwrap();
2497
2498        let engine = github_engine(store.clone(), fake.clone());
2499        let mut rx = engine.subscribe();
2500        let poller = Poller::new(engine);
2501
2502        poller.poll_github().await;
2503        let events = drain_events(&mut rx);
2504        let failures = |evs: &[Event]| evs.iter().filter(|e| matches!(
2505            e, Event::Notification(n) if n.kind == crate::types::NotificationKind::GithubLookupFailed
2506        )).count();
2507        assert_eq!(failures(&events), 1, "first failure must notify");
2508
2509        poller.poll_github().await;
2510        let events = drain_events(&mut rx);
2511        assert_eq!(failures(&events), 0, "repeated failure must not re-notify");
2512
2513        // Recovery.
2514        fake.pr_status_ok.lock().unwrap().insert(
2515            ("OwnerA".to_string(), "repoA".to_string(), 50),
2516            crate::github::PrStatus {
2517                merged: false, state: "open".into(), mergeable: Some(true),
2518                title: "t".into(), number: 50, head_sha: "abc".into(),
2519            },
2520        );
2521        poller.poll_github().await;
2522
2523        // Fails again — must notify again, since recovery cleared the flag.
2524        fake.pr_status_ok.lock().unwrap().clear();
2525        poller.poll_github().await;
2526        let events = drain_events(&mut rx);
2527        assert_eq!(failures(&events), 1, "must notify again after recovering and failing anew");
2528    }
2529
2530    // ── Comment capture (MLOPS-2455) ─────────────────────────────────────────
2531    //
2532    // `get_review_threads`'s only prior consumer was the CHANGES_REQUESTED
2533    // reaction/notification path, so a plain "Comment" review, an inline
2534    // diff comment, and the PR's main conversation-tab comments never
2535    // reached the store or the UI. These tests cover the widened capture
2536    // that persists/emits all three for display while leaving the
2537    // CHANGES_REQUESTED-only reaction gating untouched.
2538
2539    fn open_pr_session(id: &str, workspace: &str, pr_number: u64) -> Session {
2540        let mut s = test_session(id, workspace);
2541        s.repo = "Owner/repo".into();
2542        s.pr_number = Some(pr_number);
2543        s
2544    }
2545
2546    fn open_pr_fake(pr_number: u64) -> std::sync::Arc<FakeGithub> {
2547        let fake = std::sync::Arc::new(FakeGithub::default());
2548        fake.pr_status_ok.lock().unwrap().insert(
2549            ("Owner".to_string(), "repo".to_string(), pr_number),
2550            crate::github::PrStatus {
2551                merged: false, state: "open".into(), mergeable: Some(true),
2552                title: "t".into(), number: pr_number, head_sha: "abc".into(),
2553            },
2554        );
2555        fake
2556    }
2557
2558    fn comment_events(evs: &[Event]) -> Vec<Comment> {
2559        evs.iter().filter_map(|e| match e {
2560            Event::ReviewComment { comment, .. } => Some(comment.clone()),
2561            _ => None,
2562        }).collect()
2563    }
2564
2565    /// A plain "Comment" review (state COMMENTED, no diff position) is
2566    /// dropped entirely before this change — only CHANGES_REQUESTED reviews
2567    /// were converted into `Comment`s.
2568    #[tokio::test]
2569    async fn poll_github_captures_commented_review() {
2570        use crate::store::Store;
2571
2572        let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
2573        let workspace = repo_dir.path().to_string_lossy().to_string();
2574
2575        let fake = open_pr_fake(50);
2576        fake.review_threads.lock().unwrap().push(crate::github::ReviewThread {
2577            id: 501, author: "carol".into(), body: "Looks fine overall".into(),
2578            path: None, line: None, state: "COMMENTED".into(), created_at: 1_000,
2579        });
2580
2581        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2582        store.upsert_session(&open_pr_session("s1", &workspace, 50)).unwrap();
2583
2584        let engine = github_engine(store.clone(), fake);
2585        let mut rx = engine.subscribe();
2586        let poller = Poller::new(engine);
2587        poller.poll_github().await;
2588
2589        let persisted = store.list_comments().unwrap();
2590        assert_eq!(persisted.len(), 1);
2591        assert_eq!(persisted[0].id, 501);
2592        assert_eq!(persisted[0].body, "Looks fine overall");
2593        assert_eq!(persisted[0].created_at, 1_000, "created_at must come from the GitHub payload, not be hard-coded 0");
2594
2595        let emitted = comment_events(&drain_events(&mut rx));
2596        assert_eq!(emitted.len(), 1, "must emit Event::ReviewComment for display even though it's not CHANGES_REQUESTED");
2597        assert_eq!(emitted[0].id, 501);
2598    }
2599
2600    /// The empty-body filter added for the widened COMMENTED capture must
2601    /// not narrow the pre-existing CHANGES_REQUESTED path: a "Request
2602    /// changes" review with no top-level summary (only inline comments)
2603    /// still has an empty `body`, and must still be captured and still
2604    /// drive the reaction/notification path exactly as before this change.
2605    #[tokio::test]
2606    async fn poll_github_captures_changes_requested_review_with_empty_body() {
2607        use crate::store::Store;
2608
2609        let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
2610        let workspace = repo_dir.path().to_string_lossy().to_string();
2611
2612        let fake = open_pr_fake(50);
2613        fake.review_threads.lock().unwrap().push(crate::github::ReviewThread {
2614            id: 503, author: "frank".into(), body: String::new(),
2615            path: None, line: None, state: "CHANGES_REQUESTED".into(), created_at: 4_000,
2616        });
2617
2618        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2619        store.upsert_session(&open_pr_session("s1", &workspace, 50)).unwrap();
2620
2621        let engine = github_engine(store.clone(), fake);
2622        let mut rx = engine.subscribe();
2623        let poller = Poller::new(engine);
2624        poller.poll_github().await;
2625
2626        let persisted = store.list_comments().unwrap();
2627        assert_eq!(persisted.len(), 1, "an empty-body CHANGES_REQUESTED review must still be captured");
2628        assert_eq!(persisted[0].id, 503);
2629
2630        let events = drain_events(&mut rx);
2631        assert_eq!(comment_events(&events).len(), 1);
2632        assert!(
2633            events.iter().any(|e| matches!(
2634                e, Event::Notification(n) if n.kind == crate::types::NotificationKind::PrNeedsAttention
2635            )),
2636            "an empty-body CHANGES_REQUESTED review must still trigger the reaction/notification path",
2637        );
2638    }
2639
2640    /// An inline diff comment — `get_review_threads` tags these COMMENTED
2641    /// too (see `github.rs`) — must be captured with its `path`/`line`.
2642    #[tokio::test]
2643    async fn poll_github_captures_inline_review_comment() {
2644        use crate::store::Store;
2645
2646        let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
2647        let workspace = repo_dir.path().to_string_lossy().to_string();
2648
2649        let fake = open_pr_fake(50);
2650        fake.review_threads.lock().unwrap().push(crate::github::ReviewThread {
2651            id: 502, author: "dave".into(), body: "nit: rename this".into(),
2652            path: Some("src/main.rs".into()), line: Some(42),
2653            state: "COMMENTED".into(), created_at: 2_000,
2654        });
2655
2656        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2657        store.upsert_session(&open_pr_session("s1", &workspace, 50)).unwrap();
2658
2659        let engine = github_engine(store.clone(), fake);
2660        let poller = Poller::new(engine);
2661        poller.poll_github().await;
2662
2663        let persisted = store.list_comments().unwrap();
2664        assert_eq!(persisted.len(), 1);
2665        assert_eq!(persisted[0].path.as_deref(), Some("src/main.rs"));
2666        assert_eq!(persisted[0].line, Some(42));
2667    }
2668
2669    /// Issue-level conversation comments (`GET .../issues/{n}/comments`) are
2670    /// a separate GitHub endpoint from review threads entirely — before this
2671    /// change nothing ever called it.
2672    #[tokio::test]
2673    async fn poll_github_captures_issue_comment() {
2674        use crate::store::Store;
2675
2676        let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
2677        let workspace = repo_dir.path().to_string_lossy().to_string();
2678
2679        let fake = open_pr_fake(50);
2680        fake.issue_comments.lock().unwrap().push(Comment {
2681            id: 900, pr_id: 0, author: "erin".into(), body: "Great work, merging soon".into(),
2682            path: None, line: None, created_at: 3_000,
2683        });
2684
2685        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2686        store.upsert_session(&open_pr_session("s1", &workspace, 50)).unwrap();
2687
2688        let engine = github_engine(store.clone(), fake);
2689        let mut rx = engine.subscribe();
2690        let poller = Poller::new(engine);
2691        poller.poll_github().await;
2692
2693        let persisted = store.list_comments().unwrap();
2694        assert_eq!(persisted.len(), 1);
2695        assert_eq!(persisted[0].id, 900);
2696        assert_eq!(persisted[0].pr_id, 50, "pr_id must be stamped from the resolved PR, not left at the fake's placeholder value");
2697
2698        let emitted = comment_events(&drain_events(&mut rx));
2699        assert_eq!(emitted.len(), 1);
2700        assert_eq!(emitted[0].author, "erin");
2701    }
2702
2703    /// Two ticks of the *same* poller (same `enrichment_cache`) must not
2704    /// persist or re-emit a comment already seen — `seen_comment_ids` skips
2705    /// it entirely on the second tick.
2706    #[tokio::test]
2707    async fn poll_github_does_not_duplicate_comments_across_repeated_polls() {
2708        use crate::store::Store;
2709
2710        let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
2711        let workspace = repo_dir.path().to_string_lossy().to_string();
2712
2713        let fake = open_pr_fake(50);
2714        fake.review_threads.lock().unwrap().push(crate::github::ReviewThread {
2715            id: 501, author: "carol".into(), body: "Looks fine overall".into(),
2716            path: None, line: None, state: "COMMENTED".into(), created_at: 1_000,
2717        });
2718
2719        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2720        store.upsert_session(&open_pr_session("s1", &workspace, 50)).unwrap();
2721
2722        let engine = github_engine(store.clone(), fake);
2723        let mut rx = engine.subscribe();
2724        let poller = Poller::new(engine);
2725
2726        poller.poll_github().await;
2727        assert_eq!(comment_events(&drain_events(&mut rx)).len(), 1);
2728
2729        poller.poll_github().await;
2730        assert_eq!(
2731            comment_events(&drain_events(&mut rx)).len(), 0,
2732            "the second tick's in-memory seen_comment_ids must skip an already-captured comment",
2733        );
2734        assert_eq!(store.list_comments().unwrap().len(), 1);
2735    }
2736
2737    /// A restart resets `enrichment_cache` (and so `seen_comment_ids`) to
2738    /// empty — the next poll after restart will see the same comment as
2739    /// "new" again from GitHub's point of view. `upsert_comment`'s INSERT OR
2740    /// REPLACE (keyed by GitHub's comment id) must absorb that re-fetch
2741    /// without duplicating the row, so `App::new`'s `list_comments`
2742    /// hydration never shows a comment twice.
2743    #[tokio::test]
2744    async fn poll_github_restart_rehydrates_without_duplicating_comments() {
2745        use crate::store::Store;
2746
2747        let repo_dir = init_git_repo("worker-branch", &[("origin", "https://github.com/Owner/repo.git")]);
2748        let workspace = repo_dir.path().to_string_lossy().to_string();
2749
2750        let fake = open_pr_fake(50);
2751        fake.review_threads.lock().unwrap().push(crate::github::ReviewThread {
2752            id: 501, author: "carol".into(), body: "Looks fine overall".into(),
2753            path: None, line: None, state: "COMMENTED".into(), created_at: 1_000,
2754        });
2755
2756        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2757        store.upsert_session(&open_pr_session("s1", &workspace, 50)).unwrap();
2758
2759        {
2760            let engine = github_engine(store.clone(), fake.clone());
2761            let poller = Poller::new(engine);
2762            poller.poll_github().await;
2763        }
2764        assert_eq!(store.list_comments().unwrap().len(), 1);
2765
2766        // Fresh `Poller`/`Engine` — a fresh `enrichment_cache`, simulating a
2767        // process restart against the same on-disk store.
2768        let engine = github_engine(store.clone(), fake);
2769        let poller = Poller::new(engine);
2770        poller.poll_github().await;
2771
2772        let persisted = store.list_comments().unwrap();
2773        assert_eq!(persisted.len(), 1, "restart must not duplicate an already-persisted comment");
2774        assert_eq!(persisted[0].id, 501);
2775    }
2776
2777    // ── Update check ─────────────────────────────────────────────────────────
2778
2779    /// Returns whatever version is currently set in `0` — swappable mid-test
2780    /// so "a newer version than the one we already notified about" is
2781    /// exercisable without a real registry.
2782    struct FakeUpdateSource(std::sync::Mutex<Option<semver::Version>>);
2783
2784    #[async_trait::async_trait]
2785    impl UpdateSource for FakeUpdateSource {
2786        async fn latest_version(&self, _package: &str) -> anyhow::Result<Option<semver::Version>> {
2787            Ok(self.0.lock().unwrap().clone())
2788        }
2789    }
2790
2791    fn bare_engine() -> Arc<Engine> {
2792        use crate::store::Store;
2793        let store = Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2794        Engine::new(store)
2795    }
2796
2797    fn update_events(evs: &[Event]) -> usize {
2798        evs.iter().filter(|e| matches!(
2799            e, Event::Notification(n) if n.kind == crate::types::NotificationKind::UpdateAvailable
2800        )).count()
2801    }
2802
2803    #[tokio::test]
2804    async fn poll_update_check_notifies_once_then_again_for_a_newer_version() {
2805        let engine = bare_engine();
2806        let mut rx = engine.subscribe();
2807        let source = Arc::new(FakeUpdateSource(std::sync::Mutex::new(Some(semver::Version::new(99, 0, 0)))));
2808        let poller = Poller::new(engine).with_update_source(source.clone());
2809
2810        poller.poll_update_check().await;
2811        assert_eq!(update_events(&drain_events(&mut rx)), 1, "first sighting of a newer version must notify");
2812
2813        poller.poll_update_check().await;
2814        assert_eq!(update_events(&drain_events(&mut rx)), 0, "same version again must not re-notify");
2815
2816        *source.0.lock().unwrap() = Some(semver::Version::new(100, 0, 0));
2817        poller.poll_update_check().await;
2818        assert_eq!(update_events(&drain_events(&mut rx)), 1, "an even newer version must notify again");
2819    }
2820
2821    #[tokio::test]
2822    async fn poll_update_check_no_notification_when_already_current() {
2823        let engine = bare_engine();
2824        let mut rx = engine.subscribe();
2825        let current: semver::Version = env!("CARGO_PKG_VERSION").parse().unwrap();
2826        let source = Arc::new(FakeUpdateSource(std::sync::Mutex::new(Some(current))));
2827        let poller = Poller::new(engine).with_update_source(source);
2828
2829        poller.poll_update_check().await;
2830        assert_eq!(update_events(&drain_events(&mut rx)), 0);
2831    }
2832
2833    #[tokio::test]
2834    async fn poll_update_check_no_notification_when_source_has_nothing() {
2835        let engine = bare_engine();
2836        let mut rx = engine.subscribe();
2837        let source = Arc::new(FakeUpdateSource(std::sync::Mutex::new(None)));
2838        let poller = Poller::new(engine).with_update_source(source);
2839
2840        poller.poll_update_check().await;
2841        assert_eq!(update_events(&drain_events(&mut rx)), 0);
2842    }
2843
2844    // ── Brain harvest ────────────────────────────────────────────────────────
2845
2846    use std::{future::Future, pin::Pin};
2847
2848    /// Records every call it receives on `calls` and resolves with a
2849    /// caller-configured outcome — never spawns a real process or touches
2850    /// the network.
2851    struct FakeHarvestRunner {
2852        calls:   tokio::sync::mpsc::UnboundedSender<(String, PathBuf, PathBuf)>,
2853        outcome: Result<(), String>,
2854    }
2855
2856    impl HarvestRunner for FakeHarvestRunner {
2857        fn run(
2858            &self,
2859            prompt:     String,
2860            workspace:  PathBuf,
2861            brain_path: PathBuf,
2862        ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>> {
2863            let _ = self.calls.send((prompt, workspace, brain_path));
2864            let outcome = self.outcome.clone();
2865            Box::pin(async move {
2866                outcome.map_err(|e| anyhow::anyhow!(e))
2867            })
2868        }
2869    }
2870
2871    /// A repo on an explicit `main` branch (so default-branch detection is
2872    /// deterministic regardless of the machine's `init.defaultBranch`),
2873    /// checked out onto `feature_branch` with an optional extra commit —
2874    /// this is the diff `compute_nontrivial_diff` sees.
2875    fn init_diff_repo(feature_branch: &str, extra_file: Option<(&str, &str)>) -> std::path::PathBuf {
2876        let dir = tempfile::tempdir().unwrap().keep();
2877        let run = |args: &[&str]| {
2878            std::process::Command::new("git")
2879                .args(["-C", dir.to_str().unwrap()])
2880                .args(args)
2881                .output()
2882                .unwrap()
2883        };
2884        run(&["init", "-q", "-b", "main"]);
2885        run(&["config", "user.email", "test@example.com"]);
2886        run(&["config", "user.name", "Test"]);
2887        std::fs::write(dir.join("README.md"), "x").unwrap();
2888        run(&["add", "."]);
2889        run(&["commit", "-q", "-m", "init"]);
2890        run(&["checkout", "-q", "-b", feature_branch]);
2891        if let Some((name, contents)) = extra_file {
2892            std::fs::write(dir.join(name), contents).unwrap();
2893            run(&["add", name]);
2894            run(&["commit", "-q", "-m", "feature work"]);
2895        }
2896        dir
2897    }
2898
2899    /// Point `NINOX_CONFIG` at a path that doesn't exist, so `AppConfig::load()`
2900    /// falls back to `AppConfig::default()` (brain harvest enabled) rather
2901    /// than risking a real config file on the machine running the tests.
2902    fn nonexistent_config_path() -> std::path::PathBuf {
2903        tempfile::tempdir().unwrap().keep().join("nonexistent-ninox-config.toml")
2904    }
2905
2906    /// A worker session whose PR was just detected, with a real non-trivial
2907    /// diff on its branch, triggers exactly one background harvest attempt —
2908    /// and never a second one on a later tick, since `pr_number.is_none()`
2909    /// has already flipped.
2910    #[allow(clippy::await_holding_lock)]
2911    #[tokio::test]
2912    async fn metadata_sync_triggers_brain_harvest_exactly_once_on_pr_detection() {
2913        use crate::{config::ENV_TEST_GUARD, store::Store};
2914
2915        let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2916        let prior = std::env::var("NINOX_CONFIG").ok();
2917        std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
2918
2919        let repo = init_diff_repo("feature-1", Some(("src.rs", "fn main() {}\n")));
2920        let workspace = repo.to_str().unwrap().to_string();
2921
2922        let sessions_dir = tempfile::tempdir().unwrap();
2923        let meta = serde_json::json!({"agentReportedPrNumber": "9"});
2924        std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
2925
2926        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2927        store.upsert_session(&test_session("s1", &workspace)).unwrap();
2928        let engine = Engine::new(store.clone());
2929
2930        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2931        let runner = Arc::new(FakeHarvestRunner { calls: tx, outcome: Ok(()) });
2932        let poller = Poller::new_with_harvest_runner(engine, runner);
2933
2934        poller.sync_sessions_metadata(sessions_dir.path()).await;
2935
2936        let (prompt, ..) = tokio::time::timeout(Duration::from_secs(2), rx.recv())
2937            .await
2938            .expect("harvest should be attempted")
2939            .expect("channel should not be closed");
2940        assert!(prompt.contains("src.rs") && prompt.contains("fn main"), "prompt must include the diff");
2941
2942        // Second tick: pr_number is already Some, so the transition guard
2943        // must not fire the harvest again.
2944        poller.sync_sessions_metadata(sessions_dir.path()).await;
2945        assert!(rx.try_recv().is_err(), "harvest must fire exactly once per session");
2946
2947        match prior {
2948            Some(v) => std::env::set_var("NINOX_CONFIG", v),
2949            None    => std::env::remove_var("NINOX_CONFIG"),
2950        }
2951    }
2952
2953    /// A worker spawned against a non-default catalogue (`session.catalogue_path`,
2954    /// set from that worker's own `NINOX_BRAIN` at spawn time — see
2955    /// `ninox_app::main::run_spawn`) must have its harvest write to that same
2956    /// catalogue, not silently fall back to the global default brain path.
2957    #[allow(clippy::await_holding_lock)]
2958    #[tokio::test]
2959    async fn metadata_sync_brain_harvest_prefers_session_catalogue_path_over_default() {
2960        use crate::{config::ENV_TEST_GUARD, store::Store};
2961
2962        let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2963        let prior = std::env::var("NINOX_CONFIG").ok();
2964        std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
2965
2966        let repo = init_diff_repo("feature-catalogue", Some(("src.rs", "fn main() {}\n")));
2967        let workspace = repo.to_str().unwrap().to_string();
2968
2969        let sessions_dir = tempfile::tempdir().unwrap();
2970        let meta = serde_json::json!({"agentReportedPrNumber": "13"});
2971        std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
2972
2973        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
2974        let mut session = test_session("s1", &workspace);
2975        session.catalogue_path = Some("/custom/brain-catalogue".to_string());
2976        store.upsert_session(&session).unwrap();
2977        let engine = Engine::new(store.clone());
2978
2979        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2980        let runner = Arc::new(FakeHarvestRunner { calls: tx, outcome: Ok(()) });
2981        let poller = Poller::new_with_harvest_runner(engine, runner);
2982
2983        poller.sync_sessions_metadata(sessions_dir.path()).await;
2984
2985        let (_, _, brain_path) = tokio::time::timeout(Duration::from_secs(2), rx.recv())
2986            .await
2987            .expect("harvest should be attempted")
2988            .expect("channel should not be closed");
2989        assert_eq!(
2990            brain_path, PathBuf::from("/custom/brain-catalogue"),
2991            "harvest must target the session's own catalogue, not the global default",
2992        );
2993
2994        match prior {
2995            Some(v) => std::env::set_var("NINOX_CONFIG", v),
2996            None    => std::env::remove_var("NINOX_CONFIG"),
2997        }
2998    }
2999
3000    /// `brain_harvest.enabled = false` must suppress the harvest entirely —
3001    /// PR detection itself proceeds exactly as it would with it enabled.
3002    #[allow(clippy::await_holding_lock)]
3003    #[tokio::test]
3004    async fn metadata_sync_skips_brain_harvest_when_disabled() {
3005        use crate::{config::ENV_TEST_GUARD, store::Store};
3006
3007        let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3008        let config_dir = tempfile::tempdir().unwrap();
3009        let config_path = config_dir.path().join("config.toml");
3010        std::fs::write(&config_path, "port = 8080\nfont_size = 13.0\n\n[brain_harvest]\nenabled = false\n").unwrap();
3011        let prior = std::env::var("NINOX_CONFIG").ok();
3012        std::env::set_var("NINOX_CONFIG", &config_path);
3013
3014        let repo = init_diff_repo("feature-2", Some(("src.rs", "fn main() {}\n")));
3015        let workspace = repo.to_str().unwrap().to_string();
3016
3017        let sessions_dir = tempfile::tempdir().unwrap();
3018        let meta = serde_json::json!({"agentReportedPrNumber": "10"});
3019        std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
3020
3021        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3022        store.upsert_session(&test_session("s1", &workspace)).unwrap();
3023        let engine = Engine::new(store.clone());
3024
3025        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3026        let runner = Arc::new(FakeHarvestRunner { calls: tx, outcome: Ok(()) });
3027        let poller = Poller::new_with_harvest_runner(engine, runner);
3028
3029        poller.sync_sessions_metadata(sessions_dir.path()).await;
3030
3031        assert!(rx.try_recv().is_err(), "harvest must not fire when brain_harvest.enabled = false");
3032        let session = store.get_session("s1").unwrap().unwrap();
3033        assert_eq!(session.pr_number, Some(10), "PR detection must be unaffected by the disabled harvest");
3034        assert!(matches!(session.status, SessionStatus::PrOpen));
3035
3036        match prior {
3037            Some(v) => std::env::set_var("NINOX_CONFIG", v),
3038            None    => std::env::remove_var("NINOX_CONFIG"),
3039        }
3040    }
3041
3042    /// A session whose branch has no diff against the default branch yet
3043    /// must not trigger a harvest — nothing worth recording, and no point
3044    /// invoking an LLM call for it.
3045    #[allow(clippy::await_holding_lock)]
3046    #[tokio::test]
3047    async fn metadata_sync_skips_brain_harvest_for_trivial_diff() {
3048        use crate::{config::ENV_TEST_GUARD, store::Store};
3049
3050        let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3051        let prior = std::env::var("NINOX_CONFIG").ok();
3052        std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
3053
3054        // No extra commit — the feature branch is identical to main.
3055        let repo = init_diff_repo("feature-3", None);
3056        let workspace = repo.to_str().unwrap().to_string();
3057
3058        let sessions_dir = tempfile::tempdir().unwrap();
3059        let meta = serde_json::json!({"agentReportedPrNumber": "11"});
3060        std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
3061
3062        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3063        store.upsert_session(&test_session("s1", &workspace)).unwrap();
3064        let engine = Engine::new(store.clone());
3065
3066        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3067        let runner = Arc::new(FakeHarvestRunner { calls: tx, outcome: Ok(()) });
3068        let poller = Poller::new_with_harvest_runner(engine, runner);
3069
3070        poller.sync_sessions_metadata(sessions_dir.path()).await;
3071
3072        assert!(rx.try_recv().is_err(), "harvest must not fire for an empty diff");
3073
3074        match prior {
3075            Some(v) => std::env::set_var("NINOX_CONFIG", v),
3076            None    => std::env::remove_var("NINOX_CONFIG"),
3077        }
3078    }
3079
3080    /// A failing harvest subprocess must not affect the rest of
3081    /// `sync_sessions_metadata` — the session still transitions to `PrOpen`
3082    /// normally, and the failure is swallowed rather than propagated.
3083    #[allow(clippy::await_holding_lock)]
3084    #[tokio::test]
3085    async fn metadata_sync_survives_a_failing_brain_harvest() {
3086        use crate::{config::ENV_TEST_GUARD, store::Store};
3087
3088        let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3089        let prior = std::env::var("NINOX_CONFIG").ok();
3090        std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
3091
3092        let repo = init_diff_repo("feature-4", Some(("src.rs", "fn main() {}\n")));
3093        let workspace = repo.to_str().unwrap().to_string();
3094
3095        let sessions_dir = tempfile::tempdir().unwrap();
3096        let meta = serde_json::json!({"agentReportedPrNumber": "12"});
3097        std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
3098
3099        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3100        store.upsert_session(&test_session("s1", &workspace)).unwrap();
3101        let engine = Engine::new(store.clone());
3102
3103        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3104        let runner = Arc::new(FakeHarvestRunner {
3105            calls:   tx,
3106            outcome: Err("simulated claude -p failure".to_string()),
3107        });
3108        let poller = Poller::new_with_harvest_runner(engine, runner);
3109
3110        poller.sync_sessions_metadata(sessions_dir.path()).await;
3111
3112        let session = store.get_session("s1").unwrap().unwrap();
3113        assert_eq!(session.pr_number, Some(12), "PR detection must succeed regardless of harvest outcome");
3114        assert!(matches!(session.status, SessionStatus::PrOpen));
3115
3116        tokio::time::timeout(Duration::from_secs(2), rx.recv())
3117            .await
3118            .expect("the failing harvest must still be attempted")
3119            .expect("channel should not be closed");
3120
3121        match prior {
3122            Some(v) => std::env::set_var("NINOX_CONFIG", v),
3123            None    => std::env::remove_var("NINOX_CONFIG"),
3124        }
3125    }
3126
3127    /// Captures every `tracing` event's formatted `message` field so tests
3128    /// can assert on log output without a real logging backend.
3129    #[derive(Clone, Default)]
3130    struct CapturedLogs(Arc<std::sync::Mutex<Vec<String>>>);
3131
3132    impl CapturedLogs {
3133        fn contains(&self, needle: &str) -> bool {
3134            self.0.lock().unwrap().iter().any(|m| m.contains(needle))
3135        }
3136    }
3137
3138    impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CapturedLogs {
3139        fn on_event(&self, event: &tracing::Event<'_>, _ctx: tracing_subscriber::layer::Context<'_, S>) {
3140            struct Visitor(String);
3141            impl tracing::field::Visit for Visitor {
3142                fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
3143                    if field.name() == "message" {
3144                        self.0 = format!("{value:?}");
3145                    }
3146                }
3147            }
3148            let mut visitor = Visitor(String::new());
3149            event.record(&mut visitor);
3150            self.0.lock().unwrap().push(visitor.0);
3151        }
3152    }
3153
3154    /// A `HarvestRunner` whose returned future panics as soon as it's
3155    /// polled — stands in for a bug inside the real harvest subprocess
3156    /// plumbing, to prove a panic is logged rather than silently lost.
3157    struct PanickingHarvestRunner;
3158
3159    impl HarvestRunner for PanickingHarvestRunner {
3160        fn run(
3161            &self,
3162            _prompt: String,
3163            _workspace: PathBuf,
3164            _brain_path: PathBuf,
3165        ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>> {
3166            Box::pin(async move { panic!("simulated harvest panic") })
3167        }
3168    }
3169
3170    /// A panicking `HarvestRunner` must produce a logged warning — the
3171    /// `tokio::spawn` `JoinHandle` is otherwise discarded and a panic would
3172    /// be completely silent (see `trigger_brain_harvest`'s supervising
3173    /// spawn).
3174    #[allow(clippy::await_holding_lock)]
3175    #[tokio::test]
3176    async fn metadata_sync_logs_a_warning_when_harvest_task_panics() {
3177        use crate::{config::ENV_TEST_GUARD, store::Store};
3178        use tracing_subscriber::{layer::SubscriberExt, Registry};
3179
3180        let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3181        let prior = std::env::var("NINOX_CONFIG").ok();
3182        std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
3183
3184        let logs = CapturedLogs::default();
3185        let _log_guard = tracing::subscriber::set_default(Registry::default().with(logs.clone()));
3186
3187        let repo = init_diff_repo("feature-panic", Some(("src.rs", "fn main() {}\n")));
3188        let workspace = repo.to_str().unwrap().to_string();
3189
3190        let sessions_dir = tempfile::tempdir().unwrap();
3191        let meta = serde_json::json!({"agentReportedPrNumber": "20"});
3192        std::fs::write(sessions_dir.path().join("s1.json"), serde_json::to_string(&meta).unwrap()).unwrap();
3193
3194        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3195        store.upsert_session(&test_session("s1", &workspace)).unwrap();
3196        let engine = Engine::new(store.clone());
3197
3198        let poller = Poller::new_with_harvest_runner(engine, Arc::new(PanickingHarvestRunner));
3199        poller.sync_sessions_metadata(sessions_dir.path()).await;
3200
3201        // The harvest + its supervising task are detached spawns; poll
3202        // until the panic has been caught and logged, bounded so a
3203        // regression fails the test instead of hanging.
3204        let deadline = std::time::Instant::now() + Duration::from_secs(5);
3205        while !logs.contains("brain harvest task panicked") {
3206            assert!(std::time::Instant::now() < deadline, "timed out waiting for the panic to be logged");
3207            tokio::time::sleep(Duration::from_millis(20)).await;
3208        }
3209
3210        match prior {
3211            Some(v) => std::env::set_var("NINOX_CONFIG", v),
3212            None    => std::env::remove_var("NINOX_CONFIG"),
3213        }
3214    }
3215
3216    /// Records whether it ever observed two overlapping `run()` calls —
3217    /// proves the per-vault lock actually serializes concurrent harvests
3218    /// targeting the same brain path, rather than merely happening not to
3219    /// race in this particular run.
3220    struct OverlapDetectingHarvestRunner {
3221        calls:      tokio::sync::mpsc::UnboundedSender<()>,
3222        active:     Arc<std::sync::atomic::AtomicUsize>,
3223        overlapped: Arc<std::sync::atomic::AtomicBool>,
3224    }
3225
3226    impl HarvestRunner for OverlapDetectingHarvestRunner {
3227        fn run(
3228            &self,
3229            _prompt: String,
3230            _workspace: PathBuf,
3231            _brain_path: PathBuf,
3232        ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>> {
3233            let _ = self.calls.send(());
3234            let active = self.active.clone();
3235            let overlapped = self.overlapped.clone();
3236            Box::pin(async move {
3237                if active.fetch_add(1, std::sync::atomic::Ordering::SeqCst) > 0 {
3238                    overlapped.store(true, std::sync::atomic::Ordering::SeqCst);
3239                }
3240                tokio::time::sleep(Duration::from_millis(50)).await;
3241                active.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
3242                Ok(())
3243            })
3244        }
3245    }
3246
3247    /// Two sessions whose harvests target the same (default) brain vault —
3248    /// neither sets `catalogue_path`, so both resolve to
3249    /// `config.resolved_brain_path()` — must never have their
3250    /// `HarvestRunner::run` calls (which, in production, both invoke `ninox
3251    /// brain index`) overlap.
3252    #[allow(clippy::await_holding_lock)]
3253    #[tokio::test]
3254    async fn concurrent_harvests_to_the_same_vault_do_not_overlap() {
3255        use crate::{config::ENV_TEST_GUARD, store::Store};
3256
3257        let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3258        let prior = std::env::var("NINOX_CONFIG").ok();
3259        std::env::set_var("NINOX_CONFIG", nonexistent_config_path());
3260
3261        let repo1 = init_diff_repo("feature-vault-1", Some(("a.rs", "fn a() {}\n")));
3262        let repo2 = init_diff_repo("feature-vault-2", Some(("b.rs", "fn b() {}\n")));
3263
3264        let sessions_dir = tempfile::tempdir().unwrap();
3265        for (id, pr) in [("s1", "30"), ("s2", "31")] {
3266            let meta = serde_json::json!({"agentReportedPrNumber": pr});
3267            std::fs::write(sessions_dir.path().join(format!("{id}.json")), serde_json::to_string(&meta).unwrap()).unwrap();
3268        }
3269
3270        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3271        store.upsert_session(&test_session("s1", repo1.to_str().unwrap())).unwrap();
3272        store.upsert_session(&test_session("s2", repo2.to_str().unwrap())).unwrap();
3273        let engine = Engine::new(store.clone());
3274
3275        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3276        let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3277        let overlapped = Arc::new(std::sync::atomic::AtomicBool::new(false));
3278        let runner = Arc::new(OverlapDetectingHarvestRunner {
3279            calls: tx, active: active.clone(), overlapped: overlapped.clone(),
3280        });
3281        let poller = Poller::new_with_harvest_runner(engine, runner);
3282
3283        poller.sync_sessions_metadata(sessions_dir.path()).await;
3284
3285        for _ in 0..2 {
3286            tokio::time::timeout(Duration::from_secs(2), rx.recv())
3287                .await
3288                .expect("both harvests should be attempted")
3289                .expect("channel should not be closed");
3290        }
3291        // Let the (lock-serialized) second call finish its simulated work
3292        // so `overlapped` reflects the full run.
3293        tokio::time::sleep(Duration::from_millis(150)).await;
3294
3295        assert!(
3296            !overlapped.load(std::sync::atomic::Ordering::SeqCst),
3297            "concurrent harvests to the same vault must not run HarvestRunner::run concurrently",
3298        );
3299
3300        match prior {
3301            Some(v) => std::env::set_var("NINOX_CONFIG", v),
3302            None    => std::env::remove_var("NINOX_CONFIG"),
3303        }
3304    }
3305
3306    /// Two syntactically different paths to the same physical vault (here:
3307    /// a trailing slash) must resolve to the same lock — otherwise two
3308    /// harvests using differently-spelled `catalogue_path`s for the same
3309    /// vault could still run `ninox brain index` concurrently, silently
3310    /// defeating the point of the lock.
3311    #[test]
3312    fn vault_lock_treats_equivalent_paths_as_the_same_vault() {
3313        use crate::store::Store;
3314
3315        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3316        let engine = Engine::new(store);
3317        let poller = Poller::new_with_harvest_runner(engine, Arc::new(ClaudeHarvestRunner));
3318
3319        let dir = tempfile::tempdir().unwrap();
3320        let canonical = dir.path().to_path_buf();
3321        let with_trailing_slash = PathBuf::from(format!("{}/", canonical.display()));
3322
3323        let lock_a = poller.vault_lock(&canonical);
3324        let lock_b = poller.vault_lock(&with_trailing_slash);
3325
3326        assert!(
3327            Arc::ptr_eq(&lock_a, &lock_b),
3328            "syntactically different paths to the same physical vault must share one lock",
3329        );
3330    }
3331    /// The confirmed bug: a worker's process typically exits (`Terminated`)
3332    /// once its PR is merely *open*, well before that PR merges. Merge
3333    /// detection must still run for `Terminated` sessions, not just
3334    /// `Working`/`PrOpen` — otherwise the later merge becomes permanently
3335    /// invisible the instant the process dies.
3336    #[tokio::test]
3337    async fn merge_detection_fires_for_terminated_session() {
3338        use crate::store::Store;
3339
3340        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3341        let mut s = test_session("w1", "/ws");
3342        s.status = SessionStatus::Terminated;
3343        s.orchestrator_id = Some("orch1".into());
3344        s.pr_number = Some(42);
3345        store.upsert_session(&s).unwrap();
3346
3347        let engine = Engine::new(store.clone());
3348        let mut rx = engine.subscribe();
3349        let poller = Poller::new(engine);
3350
3351        let handled = poller.handle_merge_detection(&s, 42, true).await;
3352        assert!(handled, "merge detection must run for a Terminated session");
3353
3354        let after = store.get_session("w1").unwrap().unwrap();
3355        assert!(matches!(after.status, SessionStatus::Done), "merged session transitions to Done");
3356        assert!(after.terminal_at.is_some(), "Done via merge detection must stamp terminal_at");
3357
3358        let events = drain_events(&mut rx);
3359        let merged_notifs = events.iter().filter(|e| matches!(
3360            e, Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkerDone
3361        )).count();
3362        assert_eq!(merged_notifs, 1, "exactly one WorkerDone notification for the merge");
3363    }
3364
3365    /// The orchestrator must receive exactly one worker-done reaction per
3366    /// merged session — calling merge detection again for an already-`Done`
3367    /// session (as the next poll tick would, reading the updated status back
3368    /// from the store) must be a no-op, not a duplicate notification.
3369    #[tokio::test]
3370    async fn merge_detection_does_not_fire_twice_for_the_same_session() {
3371        use crate::store::Store;
3372
3373        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3374        let mut s = test_session("w1", "/ws");
3375        s.status = SessionStatus::PrOpen;
3376        s.orchestrator_id = Some("orch1".into());
3377        s.pr_number = Some(7);
3378        store.upsert_session(&s).unwrap();
3379
3380        let engine = Engine::new(store.clone());
3381        let mut rx = engine.subscribe();
3382        let poller = Poller::new(engine);
3383
3384        let first = poller.handle_merge_detection(&s, 7, true).await;
3385        assert!(first, "first tick handles the merge");
3386
3387        // Simulate the next poll tick re-reading the (now Done) session from
3388        // the store before calling merge detection again.
3389        let updated = store.get_session("w1").unwrap().unwrap();
3390        let second = poller.handle_merge_detection(&updated, 7, true).await;
3391        assert!(!second, "an already-Done session must not re-fire merge detection");
3392
3393        let events = drain_events(&mut rx);
3394        let merged_notifs = events.iter().filter(|e| matches!(
3395            e, Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkerDone
3396        )).count();
3397        assert_eq!(merged_notifs, 1, "no duplicate WorkerDone notification across ticks");
3398    }
3399
3400    #[tokio::test]
3401    async fn handle_merge_detection_is_noop_when_pr_not_merged() {
3402        use crate::store::Store;
3403
3404        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3405        let s = test_session("w1", "/ws");
3406        store.upsert_session(&s).unwrap();
3407        let engine = Engine::new(store.clone());
3408        let poller = Poller::new(engine);
3409
3410        let handled = poller.handle_merge_detection(&s, 1, false).await;
3411        assert!(!handled);
3412        assert!(matches!(store.get_session("w1").unwrap().unwrap().status, SessionStatus::Working));
3413    }
3414
3415    /// A session past the retention window is purged; one still within the
3416    /// window survives. Time is injected via `terminal_at` (computed off the
3417    /// real clock, offset by the retention window) rather than sleeping, and
3418    /// the retention config is passed in directly rather than read from
3419    /// `AppConfig::load()` — mirroring `sync_sessions_metadata`'s pattern of
3420    /// taking its directory as a parameter so tests can control it exactly.
3421    #[tokio::test]
3422    async fn sweep_retired_sessions_purges_only_past_the_retention_window() {
3423        use crate::store::Store;
3424
3425        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3426        let retention = SessionRetentionConfig { done_retention_days: 2 };
3427        let now = now_millis();
3428        let retention_ms = retention.retention_millis();
3429
3430        let mut expired = test_session("expired", "/ws");
3431        expired.status = SessionStatus::Done;
3432        expired.terminal_at = Some(now - retention_ms - 1_000);
3433        store.upsert_session(&expired).unwrap();
3434
3435        let mut fresh = test_session("fresh", "/ws");
3436        fresh.status = SessionStatus::Done;
3437        fresh.terminal_at = Some(now - 1_000);
3438        store.upsert_session(&fresh).unwrap();
3439
3440        let engine = Engine::new(store.clone());
3441        let mut rx = engine.subscribe();
3442        let poller = Poller::new(engine);
3443
3444        poller.sweep_retired_sessions(&retention).await;
3445
3446        assert!(store.get_session("expired").unwrap().is_none(), "past-retention session must be purged");
3447        assert!(store.get_session("fresh").unwrap().is_some(), "within-retention session must survive");
3448
3449        let events = drain_events(&mut rx);
3450        assert!(events.iter().any(|e| matches!(e, Event::SessionDone(id) if id == "expired")));
3451    }
3452
3453    /// A session terminated via a direct user action
3454    /// (`terminate_session`/`remove_session`) never gets `terminal_at`
3455    /// stamped — those must stay "immediate", so the sweep purges them on
3456    /// sight rather than holding them for the grace period.
3457    #[tokio::test]
3458    async fn sweep_retired_sessions_purges_sessions_with_no_terminal_at_immediately() {
3459        use crate::store::Store;
3460
3461        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3462        let mut s = test_session("user-killed", "/ws");
3463        s.status = SessionStatus::Terminated;
3464        s.terminal_at = None;
3465        store.upsert_session(&s).unwrap();
3466
3467        let engine = Engine::new(store.clone());
3468        let poller = Poller::new(engine);
3469
3470        poller.sweep_retired_sessions(&SessionRetentionConfig::default()).await;
3471
3472        assert!(store.get_session("user-killed").unwrap().is_none());
3473    }
3474
3475    /// An orchestrator's own session row must never be auto-purged by the
3476    /// retention sweep, no matter its status or how stale `terminal_at` is.
3477    #[tokio::test]
3478    async fn sweep_retired_sessions_never_purges_orchestrator_sessions() {
3479        use crate::store::Store;
3480
3481        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3482        store.upsert_orchestrator(&crate::types::Orchestrator {
3483            id: "orch1".into(), name: "orch".into(), created_at: 0,
3484        }).unwrap();
3485        let mut s = test_session("orch1", "/ws");
3486        s.status = SessionStatus::Done;
3487        s.terminal_at = Some(0); // maximally stale
3488        store.upsert_session(&s).unwrap();
3489
3490        let engine = Engine::new(store.clone());
3491        let poller = Poller::new(engine);
3492
3493        poller.sweep_retired_sessions(&SessionRetentionConfig::default()).await;
3494
3495        assert!(store.get_session("orch1").unwrap().is_some(), "orchestrator sessions are never auto-purged");
3496    }
3497
3498    /// Active (non-terminal) sessions are left alone by the sweep regardless
3499    /// of `terminal_at`.
3500    #[tokio::test]
3501    async fn sweep_retired_sessions_ignores_non_terminal_sessions() {
3502        use crate::store::Store;
3503
3504        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3505        let mut s = test_session("still-working", "/ws");
3506        s.status = SessionStatus::Working;
3507        s.terminal_at = None;
3508        store.upsert_session(&s).unwrap();
3509
3510        let engine = Engine::new(store.clone());
3511        let poller = Poller::new(engine);
3512
3513        poller.sweep_retired_sessions(&SessionRetentionConfig::default()).await;
3514
3515        assert!(store.get_session("still-working").unwrap().is_some());
3516    }
3517
3518    /// A session that reaches `Terminated` without ever going through
3519    /// `handle_merge_detection` (e.g. `poll_pids` reaping a dead process, or
3520    /// a direct `terminate_session`) must still tell its orchestrator before
3521    /// its record disappears for good — this is the gap `sweep_retired_sessions`
3522    /// used to have when it purged sessions via `store.delete_session` directly.
3523    #[tokio::test]
3524    async fn sweep_retired_sessions_notifies_orchestrator_for_never_merge_detected_session() {
3525        use crate::store::Store;
3526
3527        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3528        let mut s = test_session("w1", "/ws");
3529        s.status = SessionStatus::Terminated; // e.g. poll_pids reaping a dead process
3530        s.orchestrator_id = Some("orch1".into());
3531        s.terminal_at = Some(0); // maximally stale, past any retention window
3532        store.upsert_session(&s).unwrap();
3533
3534        let engine = Engine::new(store.clone());
3535        let mut rx = engine.subscribe();
3536        let poller = Poller::new(engine);
3537
3538        poller.sweep_retired_sessions(&SessionRetentionConfig::default()).await;
3539
3540        assert!(store.get_session("w1").unwrap().is_none(), "session must still be purged");
3541
3542        let events = drain_events(&mut rx);
3543        let retired_notifs = events.iter().filter(|e| matches!(
3544            e, Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkerRetired
3545                && n.id == "retired-w1"
3546        )).count();
3547        assert_eq!(retired_notifs, 1, "orchestrator must be told its worker was retired");
3548    }
3549
3550    /// A session that already went through `handle_merge_detection` (and so
3551    /// already got `format_worker_done_reaction`) must not be notified again
3552    /// when the retention sweep later purges it — the merge-detection
3553    /// notification is the one and only notice the orchestrator needs.
3554    #[tokio::test]
3555    async fn sweep_retired_sessions_does_not_double_notify_a_merge_detected_session() {
3556        use crate::store::Store;
3557
3558        let store = std::sync::Arc::new(Store::open(tempfile::tempdir().unwrap().keep().join("t.db")).unwrap());
3559        let mut s = test_session("w1", "/ws");
3560        s.orchestrator_id = Some("orch1".into());
3561        s.pr_number = Some(7);
3562        store.upsert_session(&s).unwrap();
3563
3564        let engine = Engine::new(store.clone());
3565        let mut rx = engine.subscribe();
3566        let poller = Poller::new(engine);
3567
3568        // The merge-detection happy path — this already notifies orch1 and
3569        // marks the session Done.
3570        assert!(poller.handle_merge_detection(&s, 7, true).await);
3571        drain_events(&mut rx); // discard the merge-detection's own notification/events
3572
3573        // Fast-forward past the retention window and let the sweep purge it.
3574        let mut done = store.get_session("w1").unwrap().unwrap();
3575        done.terminal_at = Some(0);
3576        store.upsert_session(&done).unwrap();
3577
3578        poller.sweep_retired_sessions(&SessionRetentionConfig::default()).await;
3579
3580        assert!(store.get_session("w1").unwrap().is_none(), "session must still be purged");
3581
3582        let events = drain_events(&mut rx);
3583        let retired_notifs = events.iter().filter(|e| matches!(
3584            e, Event::Notification(n) if n.kind == crate::types::NotificationKind::WorkerRetired
3585        )).count();
3586        assert_eq!(retired_notifs, 0, "must not re-notify a session already told about its merge");
3587    }
3588}