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