Skip to main content

forge_workspace/
work.rs

1//! The seat's working tree: what a scan of it found, when that was, and the
2//! loop that keeps it fresh for the seats a view is showing.
3//!
4//! **The row is pushed, not polled.** A view used to read the tree through
5//! whatever read happened to need it, which for a client meant a whole-record
6//! encode every time it wanted one number. Here the scan runs for a seat
7//! somebody is looking at, its answer is stored beside the process walk, and
8//! the row moves by itself as [`SessionUpdate::WorkChanged`].
9//!
10//! A seat nobody holds is not scanned: the hold IS what looking means, so a
11//! fleet of unheld seats costs nothing. A HELD seat costs one scan per
12//! [`SNAPSHOT_STALENESS`] while its tree is still, at most one per
13//! [`POKE_INTERVAL`] while it moves, and one when its PR lookup is due - the
14//! record a read serves carries the PR the last scan knew, so this loop is
15//! where the refresh is paid. And a seat whose row has not moved
16//! announces nothing, however often it is scanned - the frame says the tree
17//! moved, never that time passed.
18
19use std::collections::HashMap;
20use std::path::{Path, PathBuf};
21use std::sync::{Arc, Mutex};
22use std::time::{Duration, Instant};
23
24use forge_agent::env::file_index::start_change_watch;
25use forge_agent::env::processes::SCAN_STALENESS;
26use forge_primitives::SessionSlot;
27use forge_primitives::git::{GitIssueRef, GitPrInfo};
28use forge_primitives::git_diff::{BaseState, GitDiffSnapshot, GitWorkView, PrRefresh};
29
30use crate::protocol::SessionUpdate;
31use crate::workspace::Workspace;
32
33/// The repo gate, as a view reads it. Its own type so the view does not have
34/// to name the scanner's.
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum Gate {
38    /// Git answered: there is a repository here, and whatever the two
39    /// fields say about it is the whole truth.
40    #[default]
41    InRepo,
42    /// A directory with no repository behind it.
43    NotARepository,
44    /// The directory is not there at all. Git reports this as the case
45    /// above, and it is true and misleading at once: a row saying a
46    /// project is not a repository about a path that does not exist
47    /// claims something it cannot know. A despawned worker's worktree is
48    /// the usual one.
49    Gone,
50    /// Git would not run, or would not answer. Distinct from both above
51    /// because it is forge's problem rather than the project's.
52    ScannerFailed,
53}
54
55impl From<forge_primitives::git_diff::RepoGate> for Gate {
56    fn from(gate: forge_primitives::git_diff::RepoGate) -> Self {
57        match gate {
58            forge_primitives::git_diff::RepoGate::InRepo => Self::InRepo,
59            forge_primitives::git_diff::RepoGate::NotARepo => Self::NotARepository,
60            forge_primitives::git_diff::RepoGate::ScannerFailed => Self::ScannerFailed,
61        }
62    }
63}
64
65/// What one agent's working tree looks like, and what git said about it.
66///
67/// `branch` and `changed` are `None` when there is nothing to report, and
68/// `gate` is what tells the two cases apart: a directory outside a
69/// repository, a working tree that is gone, and a git that would not run
70/// all leave both fields empty and want different lines on the row.
71#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
72pub struct WorkState {
73    pub branch: Option<String>,
74    pub changed: Option<usize>,
75    pub gate: Gate,
76}
77
78/// The row a scan answers, from the scan and the directory it was taken of.
79///
80/// The directory decides one case git cannot: git calls a missing path "not a
81/// repository" too, and a row that said so about a despawned worker's
82/// worktree would claim something it cannot know.
83pub fn work_from_scan(diff: &GitDiffSnapshot, cwd: &Path) -> WorkState {
84    let gate = match diff.repo_gate {
85        forge_primitives::git_diff::RepoGate::NotARepo if !cwd.exists() => Gate::Gone,
86        other => Gate::from(other),
87    };
88    if gate != Gate::InRepo {
89        return WorkState { branch: None, changed: None, gate };
90    }
91    let branch = match &diff.branch {
92        forge_primitives::git::GitBranch::Named(name) => Some(name.clone()),
93        _ => None,
94    };
95    let changed = match &diff.worktree {
96        forge_primitives::git_diff::LayerState::Populated(stats) => Some(stats.total_files),
97        forge_primitives::git_diff::LayerState::Clean => Some(0),
98        forge_primitives::git_diff::LayerState::ScanFailed => None,
99    };
100    WorkState { branch, changed, gate }
101}
102
103/// The tree behind a row's depth, as both the record and its frames carry it:
104/// the two layers a row draws, with `Clean` and `ScanFailed` both reading as
105/// nothing to state - which is what the layer means, and the row above still
106/// carries the gate that says which.
107///
108/// `base` is the state the seat's last watch refresh measured for the layer's
109/// base. **It rides the row so a base-only move is news**: the range is
110/// `base..HEAD`, so an upstream advance the branch does not contain moves
111/// `origin/main` while the count and the chain stay exactly as they were, and
112/// a review page drawing `current` over it would keep a label nobody
113/// re-checked.
114pub fn git_work_view(
115    diff: &GitDiffSnapshot,
116    base: Option<BaseState>,
117    base_error: Option<&str>,
118) -> GitWorkView {
119    let worktree = match &diff.worktree {
120        forge_primitives::git_diff::LayerState::Populated(stats) => Some(stats.clone()),
121        forge_primitives::git_diff::LayerState::Clean
122        | forge_primitives::git_diff::LayerState::ScanFailed => None,
123    };
124    let ahead = match &diff.branch_ahead {
125        forge_primitives::git_diff::LayerState::Populated(chain) => Some(chain.clone()),
126        forge_primitives::git_diff::LayerState::Clean
127        | forge_primitives::git_diff::LayerState::ScanFailed => None,
128    };
129    GitWorkView {
130        default_branch: diff.default_branch.clone(),
131        worktree,
132        ahead,
133        base,
134        base_error: base_error.map(str::to_owned),
135    }
136}
137
138/// A seat's last scan of its working tree, and when it was taken.
139///
140/// Held whole rather than as the row alone, because the next scan takes the
141/// previous one: that is what lets the PR lookup be reused rather than
142/// repeated, and it is the scan rate-limiting its own remote lookups.
143#[derive(Clone)]
144pub struct WorkSnapshot {
145    pub diff: GitDiffSnapshot,
146    /// The directory this scan was taken of. Carried because the row is not
147    /// in the scan alone: whether a missing path is a gone worktree or a
148    /// directory outside a repository is a fact about the path.
149    pub cwd: PathBuf,
150    pub read_at: Instant,
151    /// What the checkout's last completed base refresh measured, carried
152    /// across scans that do not refresh. The row draws it, so a base that
153    /// moved without the count moving is still a frame.
154    pub base: Option<BaseState>,
155    /// When the base above was established: the instant a completed refresh
156    /// measured it, or the instant a drawing read decided to answer its base
157    /// unchecked. **What tells a measurement that is news from one already
158    /// superseded.** A refresh that finished before the read must not
159    /// overwrite the state the read found; one that finished after it is the
160    /// answer a page waiting on an unchecked base is owed.
161    pub base_at: Option<Instant>,
162    /// Git's own words for a refresh that could not run, carried with the
163    /// state so a page that only hears the row still names the reason. Empty
164    /// when the state was measured successfully or came from a read.
165    pub base_error: Option<String>,
166}
167
168/// How long a scan answers for: the staleness rule for the snapshot every
169/// view draws, so all views live with one freshness.
170pub const SNAPSHOT_STALENESS: Duration = Duration::from_secs(10);
171
172/// How long a drawing read waits for a scan already in flight.
173///
174/// The seat's gate is held across the watch's whole pass - its PR lookup
175/// included - so a review page's read that queued behind one would wait the
176/// watch's work rather than its own. The base fetch is not in that pass: a
177/// scan kicks it and never waits. Past this bound the read is refused, and it
178/// re-reads the tree locally against the base it refreshed: the store may
179/// predate that base.
180pub const PINNED_GATE_WAIT: Duration = Duration::from_secs(3);
181
182/// A shorter gate wait for a test, in milliseconds; `0` restores production's.
183///
184/// The bound is real time by nature, and a suite that spends three seconds on
185/// it per case runs the gate on the clock rather than on what it pins.
186#[cfg(any(test, feature = "testing"))]
187pub fn set_pinned_gate_wait(ms: u64) {
188    TEST_PINNED_GATE_WAIT.store(ms, std::sync::atomic::Ordering::Relaxed);
189}
190
191#[cfg(any(test, feature = "testing"))]
192static TEST_PINNED_GATE_WAIT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
193
194/// The bound a drawing read uses: production's, or a test's shorter one.
195fn pinned_gate_wait() -> Duration {
196    #[cfg(any(test, feature = "testing"))]
197    {
198        let ms = TEST_PINNED_GATE_WAIT.load(std::sync::atomic::Ordering::Relaxed);
199        if ms > 0 {
200            return Duration::from_millis(ms);
201        }
202    }
203    PINNED_GATE_WAIT
204}
205
206/// How often a held seat looks at its tree.
207///
208/// The poke is a timestamp check, and [`should_scan`] is what decides whether
209/// a subprocess runs - so this is also the ceiling on how fast a tree under
210/// sustained change can be scanned, one scan per poke per held seat.
211pub const POKE_INTERVAL: Duration = Duration::from_secs(1);
212
213/// How long a walked file index answers for: the window the socket's own read
214/// served it for before the push landed, so a seat's `@` list is no fresher
215/// and no staler than it was.
216pub const INDEX_STALENESS: Duration = Duration::from_secs(5);
217
218/// A seat's walked file index, and when it was taken.
219#[derive(Clone)]
220pub struct HeldFileIndex {
221    pub index: Arc<crate::file_index::FileIndex>,
222    pub read_at: Instant,
223}
224
225/// Whether the tree should be read again.
226///
227/// Three reasons, and the middle one is what watching buys: nothing read yet,
228/// a tree that moved since the last read, and a read that has simply gone
229/// stale - the last being what covers everything the watcher cannot report,
230/// a commit or a stage among them.
231pub fn should_scan(snapshot: Option<&WorkSnapshot>, dirty: bool, now: Instant) -> bool {
232    reads_again(snapshot.map(|held| held.read_at), dirty, now, SNAPSHOT_STALENESS)
233}
234
235/// Whether a read whose answer was taken at `read_at` should be taken again,
236/// on the same three reasons for every read this module owns.
237fn reads_again(read_at: Option<Instant>, dirty: bool, now: Instant, window: Duration) -> bool {
238    match read_at {
239        None => true,
240        Some(at) => dirty || now.saturating_duration_since(at) >= window,
241    }
242}
243
244/// The base measurement a scan adopts: the one its caller passed, else the
245/// checkout's latest when that was established before the scan began.
246///
247/// **A measurement that lands while the tree is being read is not this scan's
248/// own**: it describes a ref the row's count and files may predate, so the
249/// next pass carries it, read against it. A caller's own answer is never
250/// filtered - a drawing read's refresh is the check that read exists to make.
251fn adopted_measurement(
252    passed: Option<forge_agent::env::git_diff::BaseMeasurement>,
253    latest: Option<forge_agent::env::git_diff::BaseMeasurement>,
254    scan_started: Instant,
255) -> Option<forge_agent::env::git_diff::BaseMeasurement> {
256    passed.or_else(|| latest.filter(|measured| measured.at <= scan_started))
257}
258
259/// The row a seat's viewers were last told, which is what keeps a still seat
260/// from announcing itself again every time it is read.
261#[derive(Clone, Debug, PartialEq)]
262struct Announced {
263    work: WorkState,
264    git: GitWorkView,
265    pr: Option<GitPrInfo>,
266    closes: Vec<GitIssueRef>,
267}
268
269impl Announced {
270    fn of(held: &WorkSnapshot) -> Self {
271        Self {
272            work: work_from_scan(&held.diff, &held.cwd),
273            // **The base's words ride the row with its state**, so a landed
274            // check that failed differently is news a page can act on: the
275            // state alone may be the one it already drew.
276            git: git_work_view(&held.diff, held.base, held.base_error.as_deref()),
277            pr: held.diff.pr.clone(),
278            closes: held.diff.closes.clone(),
279        }
280    }
281}
282
283/// The seats a view is showing, and the loop each one runs.
284#[derive(Default)]
285pub(crate) struct HeldSeats {
286    held: Mutex<HashMap<SessionSlot, Held>>,
287    /// The gate a seat's reads share, by weak reference, so it outlives the
288    /// hold exactly as long as a read still holds it. A re-hold during such a
289    /// read upgrades this and shares the gate; once the read lets go the weak
290    /// dies and the next hold starts a fresh one. Dead entries are pruned on
291    /// the next look.
292    gates: Mutex<HashMap<SessionSlot, std::sync::Weak<tokio::sync::Mutex<()>>>>,
293}
294
295struct Held {
296    /// How many holders the seat has, so one view leaving does not stop a
297    /// scan another still reads.
298    count: usize,
299    /// Dropped to stop the seat's loop, which also drops its watch.
300    stop: Option<tokio::sync::oneshot::Sender<()>>,
301    /// One scan at a time for this seat: the loop's read, the record's read
302    /// and any read a fixture drives are serialised, so a second one for the
303    /// same tree is waste.
304    scanning: Arc<tokio::sync::Mutex<()>>,
305    /// Fired by the record's first read, which is what the page's snapshot
306    /// carries. The watch's first pass starts from the stores that read left,
307    /// so one read serves both halves and no value is sent twice.
308    first_read: Arc<tokio::sync::Notify>,
309    /// Wakes the loop out of its poke wait when the session reports a
310    /// repository mutation, so the tree is re-read then rather than at the
311    /// next staleness window. A commit or a stage touches only `.git`, which
312    /// the file watch filters out, so the watch itself never fires for one.
313    nudged: Arc<tokio::sync::Notify>,
314    /// Set with a nudge when the change published means the PR block the loop
315    /// holds was looked up before the PR existed, so the next scan must look
316    /// again rather than serve the cache inside its own window.
317    pr_stale: Arc<std::sync::atomic::AtomicBool>,
318    /// A test's report that the tree moved, taken by the loop as the file
319    /// watch's own mark, whose channel belongs to a thread no test can reach.
320    #[cfg(any(test, feature = "testing"))]
321    test_tree_moved: Arc<std::sync::atomic::AtomicBool>,
322    /// Raised at the end of every pass, so a test can wait for the pass that
323    /// starts from a record's read rather than clock it.
324    #[cfg(any(test, feature = "testing"))]
325    pass_done: Arc<tokio::sync::Notify>,
326}
327
328/// A first hold's share of the seat: the mark the record's read fires, the
329/// wake a reported mutation fires, and the sender that stops the loop.
330struct Taken {
331    first_read: Arc<tokio::sync::Notify>,
332    nudged: Arc<tokio::sync::Notify>,
333    stopped: Option<tokio::sync::oneshot::Receiver<()>>,
334}
335
336impl HeldSeats {
337    fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<SessionSlot, Held>> {
338        self.held.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
339    }
340
341    /// The gate for a seat's first hold: the one a read still holds, or a new
342    /// one, remembered weakly for the next re-hold.
343    fn gate_arc(&self, slot: &SessionSlot) -> Arc<tokio::sync::Mutex<()>> {
344        let mut gates = self.gates.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
345        gates.retain(|_, weak| weak.strong_count() > 0);
346        if let Some(gate) = gates.get(slot).and_then(std::sync::Weak::upgrade) {
347            return gate;
348        }
349        let gate = Arc::new(tokio::sync::Mutex::new(()));
350        gates.insert(slot.clone(), Arc::downgrade(&gate));
351        gate
352    }
353
354    /// Take a hold on `slot`, answering the mark the record's read fires and
355    /// the sender that stops the seat's loop when this is the first hold.
356    fn acquire(&self, slot: &SessionSlot) -> Taken {
357        let mut held = self.lock();
358        // An entry lives only while a hold counts: the last release removes
359        // it, so an existing one is a second viewer and owes no loop.
360        if let Some(entry) = held.get_mut(slot) {
361            entry.count += 1;
362            return Taken {
363                first_read: Arc::clone(&entry.first_read),
364                nudged: Arc::clone(&entry.nudged),
365                stopped: None,
366            };
367        }
368        let scanning = self.gate_arc(slot);
369        let first_read = Arc::new(tokio::sync::Notify::new());
370        let nudged = Arc::new(tokio::sync::Notify::new());
371        let pr_stale = Arc::new(std::sync::atomic::AtomicBool::new(false));
372        #[cfg(any(test, feature = "testing"))]
373        let test_tree_moved = Arc::new(std::sync::atomic::AtomicBool::new(false));
374        #[cfg(any(test, feature = "testing"))]
375        let pass_done = Arc::new(tokio::sync::Notify::new());
376        let (stop, stopped) = tokio::sync::oneshot::channel();
377        held.insert(
378            slot.clone(),
379            Held {
380                count: 1,
381                stop: Some(stop),
382                scanning,
383                first_read: Arc::clone(&first_read),
384                nudged: Arc::clone(&nudged),
385                pr_stale,
386                #[cfg(any(test, feature = "testing"))]
387                test_tree_moved,
388                #[cfg(any(test, feature = "testing"))]
389                pass_done,
390            },
391        );
392        Taken { first_read, nudged, stopped: Some(stopped) }
393    }
394
395    /// The seat's read gate, when one is there: a held seat's, or one a read
396    /// still holds after its hold went. A seat neither held nor read has no
397    /// loop to race, so the caller reads unguarded rather than waiting.
398    fn gate(&self, slot: &SessionSlot) -> Option<Arc<tokio::sync::Mutex<()>>> {
399        if let Some(held) = self.lock().get(slot) {
400            return Some(Arc::clone(&held.scanning));
401        }
402        let mut gates = self.gates.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
403        gates.retain(|_, weak| weak.strong_count() > 0);
404        gates.get(slot).and_then(std::sync::Weak::upgrade)
405    }
406
407    /// Say the record's first read is done, which is the mark the watch's
408    /// first pass waits on. `notify_one` holds the permit for a watch that has
409    /// not reached its wait yet, so the mark cannot be missed.
410    fn note_record_read(&self, slot: &SessionSlot) {
411        if let Some(held) = self.lock().get(slot) {
412            held.first_read.notify_one();
413        }
414    }
415
416    /// Wake the seat's loop because the session reported a repository
417    /// mutation, marking the PR block stale too when the report was a
418    /// published change. A seat nobody holds has no loop to wake.
419    fn nudge(&self, slot: &SessionSlot, pr_stale: bool) {
420        let held = self.lock();
421        if let Some(entry) = held.get(slot) {
422            if pr_stale {
423                entry.pr_stale.store(true, std::sync::atomic::Ordering::Relaxed);
424            }
425            entry.nudged.notify_one();
426        }
427    }
428
429    /// Whether a nudge asked for a PR lookup, clearing the mark.
430    fn take_pr_stale(&self, slot: &SessionSlot) -> bool {
431        self.lock()
432            .get(slot)
433            .is_some_and(|entry| entry.pr_stale.swap(false, std::sync::atomic::Ordering::Relaxed))
434    }
435
436    /// Report the tree moved, as the file watch does, for a test that drives
437    /// a pass rather than racing the OS daemon's delivery.
438    #[cfg(any(test, feature = "testing"))]
439    pub(crate) fn mark_tree_moved(&self, slot: &SessionSlot) {
440        if let Some(entry) = self.lock().get(slot) {
441            entry.test_tree_moved.store(true, std::sync::atomic::Ordering::Relaxed);
442        }
443    }
444
445    /// Whether a test reported the tree moved, clearing the mark.
446    #[cfg(any(test, feature = "testing"))]
447    fn take_tree_moved(&self, slot: &SessionSlot) -> bool {
448        self.lock().get(slot).is_some_and(|entry| {
449            entry.test_tree_moved.swap(false, std::sync::atomic::Ordering::Relaxed)
450        })
451    }
452
453    /// The mark a held seat's loop raises at the end of every pass.
454    #[cfg(any(test, feature = "testing"))]
455    pub(crate) fn pass_done(&self, slot: &SessionSlot) -> Option<Arc<tokio::sync::Notify>> {
456        self.lock().get(slot).map(|entry| Arc::clone(&entry.pass_done))
457    }
458
459    /// Give a hold back. The last hold stops the loop and drops the entry; a
460    /// read still in flight keeps its gate alive through [`Self::gates`], so
461    /// a re-hold before it finishes shares it rather than racing a second.
462    fn release(&self, slot: &SessionSlot) {
463        let mut held = self.lock();
464        let Some(entry) = held.get_mut(slot) else {
465            return;
466        };
467        entry.count = entry.count.saturating_sub(1);
468        if entry.count == 0 {
469            // The sender's drop is what stops the watch; taking it says so.
470            drop(entry.stop.take());
471            held.remove(slot);
472        }
473    }
474}
475
476impl Workspace {
477    /// Show `slot`, which is what starts its working tree being watched.
478    ///
479    /// **A hold is what "somebody is looking" means**, and it is what keeps
480    /// an idle seat from being scanned at all.
481    ///
482    /// **The hold does not wait for its reads.** The hold starts the seat's
483    /// watch and answers; the record's own read follows it straight away (the
484    /// subscribe path encodes from the stores), and the watch's first pass
485    /// starts from what that read left. Those reads ran before the hold
486    /// answered until #2506, so a warm open waited on every one of them.
487    ///
488    /// **The caller owes the read's mark.** [`Self::note_record_read`] is what
489    /// releases the watch's first pass; a hold without it has a watch that
490    /// waits and scans nothing.
491    ///
492    /// **A held seat waits for its session.** A seat with no session has no
493    /// store for a read, so the watch's own loop goes quiet on it and wakes
494    /// when the session appears - the same rule that covers a session ending
495    /// under a running loop.
496    ///
497    /// **What the old refusal cost was the viewer that never comes back.** A
498    /// subscribe to a sessionless seat was refused its hold, and most viewers
499    /// recovered by accident: a session starting is a REPLACES frame, the
500    /// reread around it unsubscribe/subscribes, and the next subscribe took
501    /// the hold - the `work_watch_quiet / session_gone` line in the live log
502    /// is one of those later holds. A viewer that nothing drove to
503    /// re-subscribe had no watch for as long as it showed the seat, and its
504    /// pushed rows stood still; the hold is unconditional now, so the FIRST
505    /// subscribe is the one that counts (#1706).
506    pub fn hold_seat(self: &Arc<Self>, slot: &SessionSlot) {
507        // A hold is somebody looking: it ends the current failure episode,
508        // so a later failure is a new one.
509        if let Some(domain) = self.domain_session_for(slot) {
510            domain.lock().auto_continue_spent = false;
511        }
512        let taken = self.held_work_seats.acquire(slot);
513        let Some(stopped) = taken.stopped else {
514            return;
515        };
516        spawn_work_watch(Arc::clone(self), slot.clone(), taken.first_read, taken.nudged, stopped);
517    }
518
519    /// Mark the seat's record read: the snapshot has taken the stores, so the
520    /// watch's first pass may start from them. The subscribe path calls this
521    /// once its record is built; a seat nobody encodes has no watch reading.
522    pub fn note_record_read(&self, slot: &SessionSlot) {
523        self.held_work_seats.note_record_read(slot);
524    }
525
526    /// The session reported a repository mutation. Re-read the tree on the
527    /// frame rather than at the next staleness window: a commit or a stage
528    /// touches only `.git`, which the file watch filters out, so nothing else
529    /// would notice for up to [`SNAPSHOT_STALENESS`].
530    pub(crate) fn note_repository_activity(&self, slot: &SessionSlot) {
531        self.held_work_seats.nudge(slot, false);
532    }
533
534    /// The session reported a published change. The held PR block was looked
535    /// up before the PR existed and its cache window would serve that answer,
536    /// so the next pass looks again.
537    pub(crate) fn note_code_change_published(&self, slot: &SessionSlot) {
538        // **The stamp is dropped here, not in the scan.** The stored snapshot
539        // outlives a `release_seat`, so a change published while nobody holds
540        // the seat would otherwise leave its attempt time in place and the
541        // seat's next open would serve the cache this event exists to
542        // invalidate. Clearing it is cheap and owes no scan.
543        if let Some(domain) = self.domain_session_for(slot)
544            && let Some(held) = domain.lock().work_snapshot.as_mut()
545        {
546            held.diff.pr_fetched_at = None;
547        }
548        self.held_work_seats.nudge(slot, true);
549    }
550
551    /// Whether a nudge asked for a PR lookup, clearing the mark.
552    fn take_pr_stale(&self, slot: &SessionSlot) -> bool {
553        self.held_work_seats.take_pr_stale(slot)
554    }
555
556    /// Wait at the fixture's gate when one is seeded. Test-only: production
557    /// reads run as they always do.
558    #[cfg(any(test, feature = "testing"))]
559    async fn pass_test_scan_gate(&self) {
560        let gate = self.test_scan_gate.lock().clone();
561        if let Some(gate) = gate {
562            // Held for the read's length: one permit is one read. A closed
563            // gate means the fixture let it go, and the read runs.
564            let _permit = gate.acquire().await;
565        }
566    }
567
568    /// Stop showing `slot`. The last hold stops its loop and its watch.
569    pub fn release_seat(&self, slot: &SessionSlot) {
570        self.held_work_seats.release(slot);
571    }
572
573    /// The seat's scan, when one has been taken.
574    pub fn work_snapshot(&self, slot: &SessionSlot) -> Option<WorkSnapshot> {
575        self.domain_session_for(slot)?.lock().work_snapshot.clone()
576    }
577
578    /// Walk `slot`'s process tree when the snapshot held is missing or older
579    /// than [`SCAN_STALENESS`], and store what the walk found.
580    ///
581    /// **The held seat's own loop is the walker** - the socket's reads used
582    /// to walk the seat a client read. This is that role moved to the loop,
583    /// so a seat nobody holds is not walked at all and the walk costs what a
584    /// held seat's tree costs rather than a whole record per read.
585    ///
586    /// The session's live backgrounded `local_bash` commands ride along, from
587    /// the registry the core holds: a backgrounded bash is `setsid`-detached
588    /// and sits outside claude's tree, so without them the walk misses the
589    /// very processes the feed leads with.
590    pub(crate) async fn walk_processes_if_stale(
591        &self,
592        slot: &SessionSlot,
593        pid: Option<u32>,
594    ) -> bool {
595        let Some(pid) = pid else {
596            return false;
597        };
598        let stale = self
599            .process_snapshot(slot)
600            .is_none_or(|held| held.scanned_at.elapsed().is_ok_and(|age| age >= SCAN_STALENESS));
601        if !stale {
602            return false;
603        }
604        #[cfg(any(test, feature = "testing"))]
605        self.pass_test_scan_gate().await;
606        let commands = local_bash_commands(
607            &self
608                .domain_session_for(slot)
609                .map(|domain| domain.lock().background_tasks.clone())
610                .unwrap_or_default(),
611        );
612        // `sysinfo`'s refresh is a CPU-bound system call rather than async I/O,
613        // so it runs on the blocking pool.
614        match tokio::task::spawn_blocking(move || forge_agent::env::processes::scan(pid, &commands))
615            .await
616        {
617            Ok(snapshot) => {
618                self.store_process_snapshot(slot, Some(snapshot));
619                true
620            }
621            Err(error) => {
622                tracing::warn!(
623                    target: "forge_workspace::work",
624                    event_name = "process_walk_failed",
625                    %error,
626                    slot = %slot.display(),
627                    "the process walk did not finish; the seat keeps the snapshot it had",
628                );
629                false
630            }
631        }
632    }
633
634    /// Walk `slot`'s tree into a file index when the one held is missing, the
635    /// tree moved, or the answer is past [`INDEX_STALENESS`], and store what
636    /// the walk found.
637    ///
638    /// **The held seat's own loop is the walker**, the role the process walk
639    /// took: the socket's reads used to walk their seat, and this walks only
640    /// the seats somebody is showing. The walk honours the user's own
641    /// gitignore preference, read per walk so a flip reaches the next one.
642    ///
643    /// **The record's read takes the same gate**, so a snapshot's first walk
644    /// and the loop's first pass are one walk rather than two of the same
645    /// tree at once.
646    pub(crate) async fn walk_files_if_stale(&self, slot: &SessionSlot, dirty: bool) -> bool {
647        let gate = self.held_work_seats.gate(slot);
648        let _one_at_a_time = match &gate {
649            Some(gate) => Some(gate.lock().await),
650            None => None,
651        };
652        self.walk_files_locked(slot, dirty).await
653    }
654
655    /// [`Self::walk_files_if_stale`] with the seat's gate already held.
656    async fn walk_files_locked(&self, slot: &SessionSlot, dirty: bool) -> bool {
657        let stale = reads_again(
658            self.file_index(slot).map(|held| held.read_at),
659            dirty,
660            Instant::now(),
661            INDEX_STALENESS,
662        );
663        if !stale {
664            return false;
665        }
666        self.walk_files_now(slot).await.is_some()
667    }
668
669    /// The record's walk of `slot`'s tree: the store when there is one, else
670    /// the walk the loop would take, under the same gate - so the snapshot's
671    /// fallback and the first pass never walk at once. The pass walks again
672    /// only when the tree moved.
673    pub async fn walk_files_for_record(
674        &self,
675        slot: &SessionSlot,
676    ) -> Arc<crate::file_index::FileIndex> {
677        let gate = self.held_work_seats.gate(slot);
678        let _one_at_a_time = match &gate {
679            Some(gate) => Some(gate.lock().await),
680            None => None,
681        };
682        if let Some(held) = self.file_index(slot) {
683            return held.index;
684        }
685        self.walk_files_now(slot).await.unwrap_or_default()
686    }
687
688    /// Walk `slot`'s tree now and store it, answering what it found. A seat
689    /// with no session has nowhere to keep it, and the walk is answered
690    /// anyway - a record still draws the tree it stands in.
691    async fn walk_files_now(
692        &self,
693        slot: &SessionSlot,
694    ) -> Option<Arc<crate::file_index::FileIndex>> {
695        #[cfg(any(test, feature = "testing"))]
696        self.pass_test_scan_gate().await;
697        let cwd = self.cwd_for_session(slot)?;
698        let respect = crate::file_index::respect_gitignore(self.user_preferences().as_ref());
699        let root = PathBuf::from(cwd);
700        let walked = root.clone();
701        // The walk is a whole tree, so it runs on the blocking pool rather
702        // than on the reactor.
703        match tokio::task::spawn_blocking(move || {
704            crate::file_index::FileIndex::scan(&root, respect)
705        })
706        .await
707        {
708            Ok(index) => {
709                let index = Arc::new(index);
710                self.store_file_index(slot, Arc::clone(&index));
711                Some(index)
712            }
713            Err(error) => {
714                tracing::warn!(
715                    target: "forge_workspace::work",
716                    event_name = "file_index_walk_failed",
717                    %error,
718                    slot = %slot.display(),
719                    path = %walked.display(),
720                    "the file-index walk did not finish; the seat keeps the index it had",
721                );
722                None
723            }
724        }
725    }
726
727    /// Store a walked file index for `slot`, as the seat's loop does.
728    pub fn store_file_index(&self, slot: &SessionSlot, index: Arc<crate::file_index::FileIndex>) {
729        if let Some(domain) = self.domain_session_for(slot) {
730            domain.lock().file_index = Some(HeldFileIndex { index, read_at: Instant::now() });
731        }
732    }
733
734    /// The seat's walked file index, when one has been taken.
735    pub fn file_index(&self, slot: &SessionSlot) -> Option<HeldFileIndex> {
736        self.domain_session_for(slot)?.lock().file_index.clone()
737    }
738
739    /// The seat's work gate, for a test that has to hold it across a read.
740    /// `None` when the seat is not held.
741    #[cfg(any(test, feature = "testing"))]
742    pub fn hold_work_gate(&self, slot: &SessionSlot) -> Option<Arc<tokio::sync::Mutex<()>>> {
743        self.held_work_seats.gate(slot)
744    }
745
746    /// Store a scan for `slot`, as the seat's loop and a fixture both do.
747    pub fn store_work_snapshot(&self, slot: &SessionSlot, snapshot: WorkSnapshot) {
748        if let Some(domain) = self.domain_session_for(slot) {
749            domain.lock().work_snapshot = Some(snapshot);
750        }
751    }
752
753    /// Answer the seat's scan, reading the tree first when the rule says to.
754    ///
755    /// **The rule is checked under the seat's own gate**, so the record's read,
756    /// the loop's read and a fixture's read cannot both decide a stale tree
757    /// needs two scans: the second one finds what the first stored.
758    ///
759    /// # Errors
760    ///
761    /// A seat with no session behind it has no directory to read, and forge
762    /// answers that rather than scanning the process's own directory - the
763    /// binary test every read here owes.
764    pub async fn scan_work_if_stale(
765        &self,
766        slot: &SessionSlot,
767        dirty: bool,
768        pr: PrRefresh,
769    ) -> Result<WorkSnapshot, String> {
770        let gate = self.held_work_seats.gate(slot);
771        let _one_at_a_time = match &gate {
772            // **A read that must not wait does not queue behind a scan.**
773            // The watch's Fetch scan holds this gate across its PR lookup,
774            // so a second open (a phone beside the desktop, a re-subscribe)
775            // would otherwise wait a network round trip it never asked for.
776            // The store it already holds is what this read would carry
777            // anyway.
778            Some(gate) if pr == PrRefresh::ServeCached => {
779                if let Ok(held) = gate.try_lock() {
780                    Some(held)
781                } else if let Some(held) = self.work_snapshot(slot) {
782                    return Ok(held);
783                } else {
784                    // Nothing stored to serve: this is the seat's first read,
785                    // and the scan in flight is the one that will store it.
786                    Some(gate.lock().await)
787                }
788            }
789            Some(gate) => Some(gate.lock().await),
790            None => None,
791        };
792        self.scan_work_locked(slot, dirty, pr, false).await
793    }
794
795    /// The seat's scan, taken because the session reported a repository
796    /// mutation rather than because its stored read aged out. `pr_force`
797    /// re-runs the PR lookup even inside the cache window the previous
798    /// attempt left behind.
799    pub(crate) async fn scan_work_now(
800        &self,
801        slot: &SessionSlot,
802        pr_force: bool,
803    ) -> Result<WorkSnapshot, String> {
804        let gate = self.held_work_seats.gate(slot);
805        let _one_at_a_time = match &gate {
806            Some(gate) => Some(gate.lock().await),
807            None => None,
808        };
809        self.scan_work_locked(slot, true, PrRefresh::Fetch, pr_force).await
810    }
811
812    /// [`Self::scan_work_if_stale`] with the seat's gate already held.
813    async fn scan_work_locked(
814        &self,
815        slot: &SessionSlot,
816        dirty: bool,
817        pr: PrRefresh,
818        force_pr: bool,
819    ) -> Result<WorkSnapshot, String> {
820        // A seat with no session has nowhere to store a scan, so the rule
821        // would read "nothing has scanned this" on every poke. The loop's own
822        // guard skips the call while sessionless, and this refusal is the
823        // second layer behind it: a session can END under a held seat between
824        // that check and this read, and then it must refuse here rather than
825        // scan into nothing.
826        if self.domain_session_for(slot).is_none() {
827            return Err(format!("{} has no session to store a scan on", slot.display()));
828        }
829        let held = self.work_snapshot(slot);
830        // **A scan that may look the PR up also runs when the PR is due.**
831        // The read path serves the stored PR and never waits, so
832        // the loop is what refreshes it; without this leg the correction
833        // would wait for the tree's own 10 s window instead of its own.
834        // No `force_pr` leg here: a forced scan arrives with `dirty = true`,
835        // so this condition is already true for it. The force does its work
836        // by handing the scan a previous block without an attempt stamp.
837        let pr_due = pr == PrRefresh::Fetch
838            && held.as_ref().is_some_and(|held| {
839                forge_agent::env::git_diff::pr_refresh_due(&held.diff, std::time::SystemTime::now())
840            });
841        // **A base refresh that landed after this snapshot is news on its own.**
842        // The tree has not moved and the snapshot is not stale, but the row's
843        // base has been checked since it was taken, and a page drawing the row
844        // would otherwise wait the ten-second window for a frame it is owed.
845        // The snapshot's own directory is the one whose base the row states.
846        let base_landed = held.as_ref().is_some_and(|held| {
847            held.diff.default_branch.as_deref().is_some_and(|branch| {
848                forge_agent::env::git_diff::last_base_measurement(&held.cwd, branch)
849                    .is_some_and(|measured| held.base_at.is_none_or(|known| measured.at > known))
850            })
851        });
852        if should_scan(held.as_ref(), dirty, Instant::now()) || pr_due || base_landed {
853            #[cfg(any(test, feature = "testing"))]
854            self.pass_test_scan_gate().await;
855            let Some(cwd) = self.cwd_for_session(slot) else {
856                return Err(format!("{} has no directory to read", slot.display()));
857            };
858            // **The watch's pass never waits on the base fetch.** The scan
859            // kicks a throttled background refresh (nobody reads its answer),
860            // and the state it measured rides the row on the next pass: a
861            // base can move without the count or the chain moving, and that
862            // move is only news if the state reaches the frame. A record read
863            // never comes here with `Fetch` except a sessionless seat's
864            // fallback, which has no store, no loop and nothing to keep fresh.
865            return Ok(self
866                .scan_work_at_with_base(slot, Path::new(&cwd), pr, None, None, false, force_pr)
867                .await);
868        }
869        self.work_snapshot(slot).ok_or_else(|| format!("{} has no stored scan", slot.display()))
870    }
871
872    /// Read `cwd` as `slot`'s working tree, store what it answered, and
873    /// answer it.
874    ///
875    /// One body for both entry points - the record's fallback and the loop -
876    /// because a second scan path is a second answer about one tree.
877    pub async fn scan_work_at(
878        &self,
879        slot: &SessionSlot,
880        cwd: &Path,
881        pr: PrRefresh,
882    ) -> WorkSnapshot {
883        self.scan_work_at_with_base(slot, cwd, pr, None, None, false, false).await
884    }
885
886    /// A sessionless seat's fetching scan with **no background kick**: the
887    /// read that draws the diff kicks the base refresh itself a moment later,
888    /// so its kick is the only one and nothing of forge's is queued ahead of
889    /// it on the shared per-repository gate.
890    pub async fn scan_work_at_without_base_fetch(
891        &self,
892        slot: &SessionSlot,
893        cwd: &Path,
894    ) -> WorkSnapshot {
895        self.scan_work_at_with_base(slot, cwd, PrRefresh::Fetch, None, None, true, false).await
896    }
897
898    /// A local scan pinned to `base`, taken **without the seat's gate**: the
899    /// drawing read's fallback when the gate is held past its bound. It writes
900    /// the store like any scan, and the caller has already refreshed the base.
901    pub async fn scan_work_at_pinned_local(
902        &self,
903        slot: &SessionSlot,
904        cwd: &Path,
905        base: Option<&str>,
906        measured: Option<forge_agent::env::git_diff::BaseMeasurement>,
907    ) -> WorkSnapshot {
908        self.scan_work_at_with_base(slot, cwd, PrRefresh::ServeCached, base, measured, false, false)
909            .await
910    }
911
912    /// [`Self::scan_work_at`] pinned to one base revision: the branch-ahead
913    /// layer is computed against `base` (a sha a drawing read resolved)
914    /// rather than the ref's name.
915    ///
916    /// `measured` is what the checkout's last completed base refresh measured,
917    /// with the instant it did and the words a failure left; `None` keeps the
918    /// state the store last carried, so a scan that did not refresh does not
919    /// erase it. **A measurement older than the base the store carries is
920    /// ignored**: a read that found the base unchecked has already superseded
921    /// it. `quiet` suppresses the background kick on a `Fetch` scan.
922    async fn scan_work_at_with_base(
923        &self,
924        slot: &SessionSlot,
925        cwd: &Path,
926        pr: PrRefresh,
927        base: Option<&str>,
928        measured: Option<forge_agent::env::git_diff::BaseMeasurement>,
929        quiet: bool,
930        force_pr: bool,
931    ) -> WorkSnapshot {
932        let scan_started = Instant::now();
933        let held = self.work_snapshot(slot);
934        // **A forced read must not consult the cache at all.** Clearing the
935        // stored stamp is not enough on its own: a Fetch scan already in
936        // flight when the change was published can store a fresh stamp after
937        // the clear, and the forced pass would then serve the pre-publication
938        // answer. Dropping the stamp from the previous block it is handed is
939        // what makes the force mean what its name says.
940        let forced_previous;
941        let previous = match held.as_ref() {
942            Some(held) if force_pr => {
943                forced_previous = forge_primitives::git_diff::GitDiffSnapshot {
944                    pr_fetched_at: None,
945                    ..held.diff.clone()
946                };
947                Some(&forced_previous)
948            }
949            Some(held) => Some(&held.diff),
950            None => None,
951        };
952        let env = self.project_env(slot.project());
953        let diff = match (pr, base) {
954            (_, Some(base)) => {
955                forge_agent::env::git_diff::scan_pinned(cwd, &env, previous, base).await
956            }
957            (PrRefresh::Fetch, None) if quiet => {
958                forge_agent::env::git_diff::scan_without_base_fetch(cwd, &env, previous).await
959            }
960            (PrRefresh::Fetch, None) => forge_agent::env::git_diff::scan(cwd, &env, previous).await,
961            (PrRefresh::ServeCached, None) => {
962                forge_agent::env::git_diff::scan_serving_cached_pr(cwd, &env, previous).await
963            }
964        };
965        // **The scan's own branch names the measurement it may adopt.**
966        let latest = diff
967            .default_branch
968            .as_deref()
969            .and_then(|branch| forge_agent::env::git_diff::last_base_measurement(cwd, branch));
970        let measured = adopted_measurement(measured, latest, scan_started);
971        let previous_base = held.as_ref().and_then(|held| held.base);
972        let previous_base_at = held.as_ref().and_then(|held| held.base_at);
973        let previous_base_error = held.as_ref().and_then(|held| held.base_error.clone());
974        // **A measurement older than the base this store carries is not
975        // news.** A drawing read that found the base unchecked wrote that
976        // state at its own decision instant, so a fetch that finished before
977        // the read must leave it alone rather than relabel the page's own
978        // answer.
979        let (base, base_at, base_error) = match measured {
980            Some(measured) if previous_base_at.is_none_or(|known| measured.at > known) => {
981                (Some(measured.state), Some(measured.at), measured.error)
982            }
983            _ => (previous_base, previous_base_at, previous_base_error),
984        };
985        let held = WorkSnapshot {
986            diff,
987            cwd: cwd.to_path_buf(),
988            read_at: Instant::now(),
989            base,
990            base_at,
991            base_error,
992        };
993        self.store_work_snapshot(slot, held.clone());
994        held
995    }
996
997    /// A scan a drawing read must take, pinned to one base revision: it
998    /// **awaits the seat's gate** rather than serving the store a concurrent
999    /// scan is replacing, so the count and chain it answers were read after
1000    /// the base moved. Local only - the caller has already refreshed the base.
1001    pub async fn scan_work_pinned(
1002        &self,
1003        slot: &SessionSlot,
1004        cwd: &Path,
1005        base: Option<&str>,
1006        measured: Option<forge_agent::env::git_diff::BaseMeasurement>,
1007    ) -> Result<WorkSnapshot, String> {
1008        if self.domain_session_for(slot).is_none() {
1009            return Err(format!("{} has no session to store a scan on", slot.display()));
1010        }
1011        let gate = self.held_work_seats.gate(slot);
1012        let wait = pinned_gate_wait();
1013        let _one_at_a_time = match &gate {
1014            Some(gate) => {
1015                // **A drawing read waits for a scan, but not for ever.** The
1016                // gate is held across the watch's whole scan - its PR lookup
1017                // included, and never its base fetch, which the scan kicks -
1018                // and a reader that queued behind that would spend the page's
1019                // open on a pass it never asked for. Past the bound the read
1020                // is refused and re-reads locally against the base it
1021                // refreshed.
1022                let Ok(held) = tokio::time::timeout(wait, gate.lock()).await else {
1023                    // DEBUG: the fallback is the design working, not a fault -
1024                    // the read answers a correct scan either way, one bound
1025                    // later, and a slow watch pass is the environment's.
1026                    tracing::debug!(
1027                        target: "forge_workspace::work",
1028                        event_name = "work_scan_gate_overrun",
1029                        slot = %slot.display(),
1030                        wait_ms = wait.as_secs_f64() * 1000.0,
1031                        "a drawing read waited the whole bound for the seat's work gate",
1032                    );
1033                    return Err(format!(
1034                        "{}'s work gate was held past {}ms",
1035                        slot.display(),
1036                        wait.as_millis(),
1037                    ));
1038                };
1039                Some(held)
1040            }
1041            None => None,
1042        };
1043        Ok(self
1044            .scan_work_at_with_base(slot, cwd, PrRefresh::ServeCached, base, measured, false, false)
1045            .await)
1046    }
1047
1048    /// `project`'s own declared environment, `env_file` included, which
1049    /// is the identity a `gh` spawn on its behalf carries: the scan's PR
1050    /// row must read as the project, not as the server's ambient login.
1051    /// A name no project has declares none, so `gh` falls back to its
1052    /// own login.
1053    fn project_env(&self, project: &str) -> std::collections::HashMap<String, String> {
1054        self.config_arc()
1055            .projects
1056            .iter()
1057            .find(|candidate| candidate.name == project)
1058            .map(|candidate| candidate.env.clone())
1059            .unwrap_or_default()
1060    }
1061}
1062
1063/// Whether a walk carries news: the entries the seat's viewers were last told
1064/// against the ones this walk found.
1065///
1066/// **`None` is "nothing ever announced", not "an empty tree"**, so the first
1067/// walk of a seat that holds nothing still announces.
1068///
1069/// **Equality is over the whole entry, so a merely running tree is news**:
1070/// `memory_bytes` drifts as processes work, so a held seat with a live tree
1071/// announces about once per poke rather than only when a process arrives.
1072/// Narrowing the comparison to ignore the memory figure would freeze the
1073/// number every viewer draws at whatever the first walk found.
1074fn walk_moved(
1075    walked: Option<&Vec<forge_agent::env::processes::ProcessEntry>>,
1076    snapshot: &forge_agent::env::processes::ProcessSnapshot,
1077) -> bool {
1078    walked != Some(&snapshot.processes)
1079}
1080
1081/// Whether the seat's watch was stopped while it waited for the record's mark:
1082/// the holder went (the sender is dropped) or the stop fired. Either way the
1083/// reads it was about to take are owed to nobody.
1084fn stopped_while_waiting(stopped: &mut tokio::sync::oneshot::Receiver<()>) -> bool {
1085    matches!(stopped.try_recv(), Ok(()) | Err(tokio::sync::oneshot::error::TryRecvError::Closed))
1086}
1087
1088/// The commands a walk hands the OS scan: each running `local_bash` task's own
1089/// command.
1090///
1091/// A task whose card forge has not seen carries no command and is skipped:
1092/// a rostered bash with no recorded command draws its registry row instead
1093/// of being adopted by the walk.
1094fn local_bash_commands(tasks: &[crate::BackgroundTask]) -> Vec<String> {
1095    tasks
1096        .iter()
1097        .filter(|task| task.task_type == "local_bash")
1098        .filter_map(|task| task.command.clone())
1099        .collect()
1100}
1101
1102/// One seat's scan loop: the tree is watched, the poke reads it and walks the
1103/// seat's process tree and its files, and whatever moved goes to whoever is
1104/// showing the seat.
1105///
1106/// **The record's read is the first pass.** The subscribe path holds the seat
1107/// and then encodes its snapshot from the stores, so that read is what the
1108/// page's first frame carries; this task waits for its mark, seeds from the
1109/// stores it left, and announces a value the page does not already hold. The
1110/// gate keeps the two reads from running together; a pass the tree moved under
1111/// still reads and announces.
1112///
1113/// **The work scan comes first in every pass.** It is the cheapest of the
1114/// three reads and the row a reader watches, so the process walk and the
1115/// whole-tree index walk queue behind it rather than delay it.
1116fn spawn_work_watch(
1117    workspace: Arc<Workspace>,
1118    slot: SessionSlot,
1119    first_read: Arc<tokio::sync::Notify>,
1120    nudged: Arc<tokio::sync::Notify>,
1121    mut stopped: tokio::sync::oneshot::Receiver<()>,
1122) {
1123    tokio::spawn(async move {
1124        let Some(cwd) = workspace.cwd_for_session(&slot) else {
1125            tracing::debug!(
1126                target: "forge_workspace::work",
1127                event_name = "work_watch_skipped",
1128                slot = %slot.display(),
1129                reason = "cwd_unresolved",
1130                "a held seat's directory is not resolvable, so nothing is watched",
1131            );
1132            return;
1133        };
1134        // The ignore filter is on whatever the file index's own preference
1135        // says: git's view of a tree never includes an ignored path, so one
1136        // of those moving cannot change the row this loop watches for.
1137        let (changes, _watch) = start_change_watch(PathBuf::from(cwd), true);
1138        tokio::select! {
1139            _ = &mut stopped => return,
1140            () = first_read.notified() => {}
1141        }
1142        // A page that opened and closed again leaves nothing to fill in, so
1143        // the expensive reads are not taken for it.
1144        if stopped_while_waiting(&mut stopped) {
1145            return;
1146        }
1147        // The stores are what the snapshot carried, so they seat each
1148        // baseline: the first pass announces only a value that differs.
1149        let mut announced = workspace.work_snapshot(&slot).map(|held| Announced::of(&held));
1150        let mut walked = workspace.process_snapshot(&slot).map(|held| held.processes);
1151        let mut indexed = workspace.file_index(&slot).map(|held| held.index);
1152        // Whether the last poke found the seat's session gone, so the quiet
1153        // spell is reported once rather than once a second.
1154        let mut quiet = false;
1155        let mut poke = tokio::time::interval(POKE_INTERVAL);
1156        poke.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1157        loop {
1158            // A reported mutation is a pass of its own: it wakes the loop out
1159            // of its poke wait so the tree is re-read on the frame rather
1160            // than up to a staleness window later.
1161            let woken = tokio::select! {
1162                _ = &mut stopped => return,
1163                _ = poke.tick() => false,
1164                () = nudged.notified() => true,
1165            };
1166            // **A seat whose session is not there - not started yet, or ended
1167            // under its loop - goes quiet, and does not go away.** There is
1168            // nothing to store or announce for a session that is absent, so
1169            // this skips the whole body. But the LOOP stays, and so does the
1170            // hold: a session that comes back under the same seat resumes
1171            // being scanned under the viewer that was already showing it, with
1172            // no re-subscribe and no entry whose loop has gone - and that is
1173            // the wake the sessionless hold depends on.
1174            //
1175            // **This guard is load-bearing; do not delete it as redundant
1176            // with the scan's own refusal.** Deleting either quiet layer
1177            // alone leaves every test green (the other swallows the
1178            // emission), so no mutation says it - but without this one, a
1179            // sessionless seat walks+scans its whole tree every poke with
1180            // every result dropped, and nothing observable would say so.
1181            if workspace.domain_session_for(&slot).is_none() {
1182                if !quiet {
1183                    quiet = true;
1184                    tracing::debug!(
1185                        target: "forge_workspace::work",
1186                        event_name = "work_watch_quiet",
1187                        slot = %slot.display(),
1188                        reason = "no_session",
1189                        "the seat has no session - not started yet, or ended under its loop - so \
1190                         nothing is scanned until one is there",
1191                    );
1192                }
1193                continue;
1194            }
1195            quiet = false;
1196            // Whatever the watch reported since the last look is one mark:
1197            // the tree moved, which is all a scan decision needs.
1198            let mut dirty = false;
1199            while changes.try_recv().is_ok() {
1200                dirty = true;
1201            }
1202            #[cfg(any(test, feature = "testing"))]
1203            {
1204                dirty |= workspace.held_work_seats.take_tree_moved(&slot);
1205            }
1206            // A published change marks the held PR stale: its lookup ran
1207            // before the PR existed, and the cache would serve that answer
1208            // for the rest of its window.
1209            let pr_force = workspace.take_pr_stale(&slot);
1210            // The tree's own row first: the cheapest read, and the one a
1211            // reader waits for. A refusal leaves the process and file walks
1212            // to run their own rules.
1213            // **The loop is where the PR lookup is paid.** Nobody
1214            // waits on this pass; the snapshot a client's open reads serves
1215            // the stored PR instead, so the round trip lands here.
1216            let read = if woken || pr_force {
1217                workspace.scan_work_now(&slot, pr_force).await
1218            } else {
1219                workspace.scan_work_if_stale(&slot, dirty, PrRefresh::Fetch).await
1220            };
1221            if let Ok(held) = read {
1222                let row = Announced::of(&held);
1223                if announced.as_ref() != Some(&row) {
1224                    workspace.update_tx.send(SessionUpdate::WorkChanged {
1225                        key: slot.clone(),
1226                        work: row.work.clone(),
1227                        git: row.git.clone(),
1228                        pr: row.pr.clone(),
1229                        closes: row.closes.clone(),
1230                    });
1231                    announced = Some(row);
1232                }
1233            } else {
1234                tracing::debug!(
1235                    target: "forge_workspace::work",
1236                    event_name = "work_watch_scan_refused",
1237                    slot = %slot.display(),
1238                    "the seat's tree was not read this poke",
1239                );
1240            }
1241            // The walk, on its own rule: missing or older than
1242            // `SCAN_STALENESS`, which is this poke's interval.
1243            workspace.walk_processes_if_stale(&slot, workspace.claude_pid(&slot)).await;
1244            // **The compare runs whoever walked.** The store can be written by
1245            // a walk other than this loop's own, so it is read on every poke
1246            // rather than only after this loop walked.
1247            if let Some(walk) = workspace.process_snapshot(&slot)
1248                && walk_moved(walked.as_ref(), &walk)
1249            {
1250                walked = Some(walk.processes.clone());
1251                workspace
1252                    .update_tx
1253                    .send(SessionUpdate::ProcessesChanged { key: slot.clone(), snapshot: walk });
1254            }
1255            // The file index, on the same mark and the same poke: the watch
1256            // that moved the tree is also what a `@` list would have to
1257            // follow, and [`INDEX_STALENESS`] is what keeps a build
1258            // directory's churn from becoming a frame per write.
1259            workspace.walk_files_if_stale(&slot, dirty).await;
1260            if let Some(held) = workspace.file_index(&slot)
1261                && indexed.as_ref() != Some(&held.index)
1262            {
1263                indexed = Some(Arc::clone(&held.index));
1264                workspace
1265                    .update_tx
1266                    .send(SessionUpdate::FileIndexChanged { key: slot.clone(), index: held.index });
1267            }
1268            #[cfg(any(test, feature = "testing"))]
1269            if let Some(done) = workspace.held_work_seats.pass_done(&slot) {
1270                done.notify_one();
1271            }
1272        }
1273    });
1274}
1275
1276#[cfg(test)]
1277mod tests {
1278    use std::process::Command;
1279    use std::time::Duration;
1280
1281    use forge_primitives::git::GitBranch;
1282    use forge_primitives::git_diff::{LayerState, RepoGate};
1283
1284    use super::*;
1285    use crate::SeatUpdate;
1286
1287    /// How long a test waits for a scan's kicked fetch to land: the scan's own
1288    /// ten-second child budget plus a loaded machine's slack, so the wait is a
1289    /// failure bound rather than a race.
1290    const KICK_SETTLE: Duration = Duration::from_secs(60);
1291
1292    fn seat() -> SessionSlot {
1293        SessionSlot::lead("TestOrg", "forge")
1294    }
1295
1296    /// Hold the seat and mark the record read, the way a subscribe does: the
1297    /// page's snapshot is the first read and the watch's first pass starts
1298    /// from the stores it left.
1299    fn hold_for_a_page(workspace: &Arc<Workspace>, seat: &SessionSlot) {
1300        workspace.hold_seat(seat);
1301        workspace.note_record_read(seat);
1302    }
1303
1304    /// A workspace whose one project IS the repository above, so a lead seat
1305    /// resolves to a tree that is really there. The config dir comes back
1306    /// with it: the store under it is open for as long as the workspace is.
1307    ///
1308    /// The seat is NOT started: the project is declared and nothing runs
1309    /// behind it - the state a hold takes and waits on (#1706), and the shape
1310    /// most of these tests begin from.
1311    fn a_declared_project(
1312        repo: &Path,
1313    ) -> (Arc<Workspace>, tokio::sync::mpsc::UnboundedReceiver<SeatUpdate>, tempfile::TempDir) {
1314        a_declared_project_with_env(repo, &[])
1315    }
1316
1317    /// [`a_declared_project`] with the project's own `[orgs.projects.env]`,
1318    /// which is the identity a scan's `git` and `gh` children carry.
1319    fn a_declared_project_with_env(
1320        repo: &Path,
1321        env: &[(&str, &str)],
1322    ) -> (Arc<Workspace>, tokio::sync::mpsc::UnboundedReceiver<SeatUpdate>, tempfile::TempDir) {
1323        use std::fmt::Write as _;
1324
1325        let dir = tempfile::tempdir().expect("tempdir");
1326        let forge = dir.path().join("forge");
1327        std::fs::create_dir_all(&forge).expect("forge dir");
1328        let mut project_env = String::new();
1329        if !env.is_empty() {
1330            project_env.push_str("\n  [orgs.projects.env]\n");
1331            for (key, value) in env {
1332                writeln!(project_env, "  {key} = {value:?}").expect("the env block writes");
1333            }
1334        }
1335        std::fs::write(
1336            forge.join("forge.toml"),
1337            format!(
1338                r#"
1339[[orgs]]
1340name = "TestOrg"
1341accounts = ["acct-a"]
1342
1343[[orgs.projects]]
1344name = "forge"
1345path = "{}"
1346{project_env}
1347[[accounts]]
1348display_name = "acct-a"
1349token = "t"
1350models = ["claude-sonnet-5"]
1351provider = "anthropic"
1352"#,
1353                repo.display(),
1354            ),
1355        )
1356        .expect("write forge.toml");
1357        let workspace =
1358            Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("workspace"));
1359        let updates = workspace.subscribe_answerer();
1360        (workspace, updates, dir)
1361    }
1362
1363    /// [`a_declared_project`] with the seat started, which is what a hold
1364    /// needs: the store rides the seat's own record.
1365    fn a_workspace(
1366        repo: &Path,
1367    ) -> (Arc<Workspace>, tokio::sync::mpsc::UnboundedReceiver<SeatUpdate>, tempfile::TempDir) {
1368        let (workspace, updates, dir) = a_declared_project(repo);
1369        workspace.register_domain_session(seat(), None);
1370        (workspace, updates, dir)
1371    }
1372
1373    /// [`a_workspace`] with the project's own env, which is where a scan's
1374    /// `gh` children get their identity.
1375    fn a_workspace_with_env(
1376        repo: &Path,
1377        env: &[(&str, &str)],
1378    ) -> (Arc<Workspace>, tokio::sync::mpsc::UnboundedReceiver<SeatUpdate>, tempfile::TempDir) {
1379        let (workspace, updates, dir) = a_declared_project_with_env(repo, env);
1380        workspace.register_domain_session(seat(), None);
1381        (workspace, updates, dir)
1382    }
1383
1384    /// A repo on a pushed `feature` branch, and a `gh` on a PATH of its own
1385    /// that answers the scan's PR lookup with one open PR and records that it
1386    /// ran. The push is simulated the way the scanner sees one: a
1387    /// remote-tracking ref that already contains the tip.
1388    struct PushedBranch {
1389        repo: tempfile::TempDir,
1390        tip: String,
1391        /// The shim directory, as the project's `PATH`.
1392        path: String,
1393        marker: std::path::PathBuf,
1394        /// The git dir's holder, when the fixture keeps it outside the tree.
1395        _git_dir: Option<tempfile::TempDir>,
1396    }
1397
1398    /// One open PR, as GitHub's `commits/<sha>/pulls` answers it.
1399    const PR_JSON: &str = r#"[{"number":108,"state":"open","html_url":"https://example.test/pull/108","updated_at":"2026-10-24T00:00:00Z","draft":false}]"#;
1400
1401    impl PushedBranch {
1402        /// The fixture and the shim's directory, which the caller has to keep
1403        /// alive for as long as `gh` may run.
1404        fn new() -> (Self, tempfile::TempDir) {
1405            Self::new_over(false)
1406        }
1407
1408        /// The same fixture with its git directory outside the tree.
1409        ///
1410        /// **What makes "a commit is invisible to the tree watch" true by
1411        /// construction.** The watch filters paths under `.git`, but how a
1412        /// backend reports a write inside it is the backend's business, and
1413        /// the Linux CI runner's marked the tree moved for a commit. With the
1414        /// git dir outside the root there is nothing for any backend to
1415        /// report, so a test of the frame's nudge cannot pass on the watch's
1416        /// speed instead of the wake.
1417        fn new_with_git_dir_outside_the_tree() -> (Self, tempfile::TempDir) {
1418            Self::new_over(true)
1419        }
1420
1421        fn new_over(git_dir_outside: bool) -> (Self, tempfile::TempDir) {
1422            use std::os::unix::fs::PermissionsExt;
1423
1424            let git_dir_holder = git_dir_outside.then(|| tempfile::tempdir().expect("a temp dir"));
1425            let repo = if let Some(holder) = &git_dir_holder {
1426                let tree = tempfile::tempdir().expect("a temp dir");
1427                let out = Command::new("git")
1428                    .env_remove("GIT_DIR")
1429                    .env_remove("GIT_WORK_TREE")
1430                    .env_remove("GIT_COMMON_DIR")
1431                    .args([
1432                        "init",
1433                        "-q",
1434                        "-b",
1435                        "main",
1436                        &format!("--separate-git-dir={}", holder.path().join("git").display()),
1437                        tree.path().to_str().expect("a utf-8 path"),
1438                    ])
1439                    .output()
1440                    .expect("run git init");
1441                assert!(
1442                    out.status.success(),
1443                    "git init --separate-git-dir: {}",
1444                    String::from_utf8_lossy(&out.stderr),
1445                );
1446                for args in [
1447                    &["config", "user.email", "test@example.test"][..],
1448                    &["config", "user.name", "test"],
1449                    &["config", "commit.gpgsign", "false"],
1450                    &["config", "maintenance.auto", "false"],
1451                    &["config", "gc.auto", "0"],
1452                ] {
1453                    git(tree.path(), args);
1454                }
1455                std::fs::write(tree.path().join("kept.txt"), "one").expect("write");
1456                git(tree.path(), &["add", "."]);
1457                git(tree.path(), &["commit", "-qm", "first"]);
1458                tree
1459            } else {
1460                a_repo()
1461            };
1462            git(repo.path(), &["checkout", "-q", "-b", "feature"]);
1463            let tip = rev_parse(repo.path(), "HEAD");
1464            git(repo.path(), &["update-ref", "refs/remotes/origin/main", &tip]);
1465            git(
1466                repo.path(),
1467                &["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"],
1468            );
1469
1470            let shim = tempfile::tempdir().expect("the gh shim's own directory");
1471            let marker = shim.path().join("gh-spawned");
1472            let gh = shim.path().join("gh");
1473            std::fs::write(
1474                &gh,
1475                format!(
1476                    "#!/bin/sh\nprintf x > {marker}\ncase \"$*\" in\n  *commits/*pulls) \
1477                     printf '%s' '{pr}' ;;\n  *\"pr view\"*) printf '%s' \
1478                     '{{\"closingIssuesReferences\":[]}}' ;;\n  *) printf '[]' ;;\nesac\n",
1479                    marker = marker.display(),
1480                    pr = PR_JSON,
1481                ),
1482            )
1483            .expect("write the shim");
1484            std::fs::set_permissions(&gh, std::fs::Permissions::from_mode(0o755)).expect("chmod");
1485            let path = shim.path().to_string_lossy().into_owned();
1486            (Self { repo, tip, path, marker, _git_dir: git_dir_holder }, shim)
1487        }
1488
1489        /// The project's env, which is the identity the scan's `gh` runs as.
1490        fn env(&self) -> [(&str, &str); 2] {
1491            [("PATH", self.path.as_str()), ("GH_TOKEN", "t")]
1492        }
1493
1494        /// A stored scan of the branch, past the read window, carrying `pr`
1495        /// with its attempt taken at `fetched_at`.
1496        fn stored_scan(
1497            &self,
1498            pr: Option<forge_primitives::git::GitPrInfo>,
1499            fetched_at: std::time::SystemTime,
1500        ) -> WorkSnapshot {
1501            WorkSnapshot {
1502                read_at: Instant::now()
1503                    .checked_sub(SNAPSHOT_STALENESS + Duration::from_secs(1))
1504                    .expect("the test clock is past the read window"),
1505                diff: GitDiffSnapshot {
1506                    branch: GitBranch::Named("feature".to_owned()),
1507                    default_branch: Some("origin/main".to_owned()),
1508                    repo_gate: RepoGate::InRepo,
1509                    pushed_sha: Some(self.tip.clone()),
1510                    worktree: LayerState::Clean,
1511                    branch_ahead: LayerState::Clean,
1512                    pr,
1513                    closes: Vec::new(),
1514                    pr_fetched_at: Some(fetched_at),
1515                },
1516                cwd: self.repo.path().to_path_buf(),
1517                base: None,
1518                base_at: None,
1519                base_error: None,
1520            }
1521        }
1522    }
1523
1524    /// The sha `rev` names in `dir`.
1525    fn rev_parse(dir: &Path, rev: &str) -> String {
1526        let out = Command::new("git")
1527            .env_remove("GIT_DIR")
1528            .env_remove("GIT_WORK_TREE")
1529            .env_remove("GIT_COMMON_DIR")
1530            .arg("-C")
1531            .arg(dir)
1532            .args(["rev-parse", rev])
1533            .output()
1534            .expect("run git");
1535        assert!(out.status.success(), "git rev-parse {rev}");
1536        String::from_utf8_lossy(&out.stdout).trim().to_owned()
1537    }
1538
1539    /// Spawn git the way the product does, scrub included: the ambient
1540    /// repo-location variables would otherwise answer about whatever
1541    /// repository this suite is itself running inside.
1542    fn git(dir: &Path, args: &[&str]) {
1543        let out = Command::new("git")
1544            .env_remove("GIT_DIR")
1545            .env_remove("GIT_WORK_TREE")
1546            .env_remove("GIT_COMMON_DIR")
1547            .arg("-C")
1548            .arg(dir)
1549            .args(args)
1550            .output()
1551            .expect("run git");
1552        assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
1553    }
1554
1555    /// A repository with one commit, which is the state a held seat's tree
1556    /// is read from.
1557    fn a_repo() -> tempfile::TempDir {
1558        let dir = tempfile::tempdir().expect("a temp dir");
1559        git(dir.path(), &["init", "-q", "-b", "main"]);
1560        git(dir.path(), &["config", "user.email", "test@example.test"]);
1561        git(dir.path(), &["config", "user.name", "test"]);
1562        git(dir.path(), &["config", "commit.gpgsign", "false"]);
1563        // The commit below leaves git's auto-maintenance behind (`git
1564        // maintenance run --auto --detach`, gc --auto's modern form), which
1565        // writes and removes `.git/objects/maintenance.lock` - the measured
1566        // cause of the still-tree announce (#1818). A tree that has to be
1567        // still runs no maintenance.
1568        git(dir.path(), &["config", "maintenance.auto", "false"]);
1569        git(dir.path(), &["config", "gc.auto", "0"]);
1570        std::fs::write(dir.path().join("kept.txt"), "one").expect("write");
1571        git(dir.path(), &["add", "."]);
1572        git(dir.path(), &["commit", "-qm", "first"]);
1573        dir
1574    }
1575
1576    fn a_snapshot(read_at: Instant, cwd: &Path) -> WorkSnapshot {
1577        WorkSnapshot {
1578            diff: GitDiffSnapshot {
1579                branch: GitBranch::Detached,
1580                pushed_sha: None,
1581                pr_fetched_at: None,
1582                default_branch: None,
1583                repo_gate: RepoGate::NotARepo,
1584                worktree: LayerState::Clean,
1585                branch_ahead: LayerState::Clean,
1586                pr: None,
1587                closes: Vec::new(),
1588            },
1589            cwd: cwd.to_path_buf(),
1590            read_at,
1591            base: None,
1592            base_at: None,
1593            base_error: None,
1594        }
1595    }
1596
1597    /// The rule the record's read and the loop both read: nothing read yet, a
1598    /// tree that moved, or a read that has gone stale.
1599    #[test]
1600    fn a_scan_happens_for_nothing_read_a_dirty_tree_or_a_stale_read() {
1601        let now = Instant::now();
1602        let held = a_snapshot(now, Path::new("/tmp/nowhere"));
1603
1604        assert!(should_scan(None, false, now), "nothing read yet is read");
1605        assert!(should_scan(Some(&held), true, now), "a tree that moved is read at once");
1606        assert!(
1607            !should_scan(Some(&held), false, now),
1608            "a fresh read of a still tree is not read again",
1609        );
1610        assert!(
1611            should_scan(Some(&held), false, now + SNAPSHOT_STALENESS),
1612            "and a read past the window is read whatever the watch said",
1613        );
1614    }
1615
1616    /// **The record's read never waits on the PR lookup.** The lookup
1617    /// is the scan's one piece of network - a TLS + API round trip measured at
1618    /// 0.9 s - and it sat on the record a client's open waits for. The record
1619    /// serves the PR its store holds and leaves the refresh to the seat's
1620    /// loop, which nobody waits on. The control below proves the fixture can
1621    /// see a spawn, so "no marker" is a policy and not a broken shim.
1622    #[tokio::test(flavor = "current_thread")]
1623    async fn the_records_scan_serves_the_stored_pr_without_spawning_gh() {
1624        use forge_primitives::git::GitPrInfo;
1625
1626        let (branch, _shim) = PushedBranch::new();
1627        let (workspace, _updates, _config) =
1628            a_workspace_with_env(branch.repo.path(), &branch.env());
1629
1630        // The store's scan is past the read window, so the record re-reads the
1631        // tree, and its PR attempt is past any refetch window - both reasons
1632        // the lookup would run today.
1633        let pr = GitPrInfo { number: 7, url: "https://example.test/pr/7".to_owned(), draft: false };
1634        let fetched_at = std::time::SystemTime::now() - Duration::from_secs(600);
1635        workspace.store_work_snapshot(&seat(), branch.stored_scan(Some(pr.clone()), fetched_at));
1636
1637        let served = workspace
1638            .scan_work_if_stale(&seat(), false, PrRefresh::ServeCached)
1639            .await
1640            .expect("a live seat reads its tree");
1641        assert!(
1642            !branch.marker.exists(),
1643            "the record's read answers without spawning the PR lookup",
1644        );
1645        assert_eq!(served.diff.pr, Some(pr), "and it serves the PR the store already held");
1646        assert_eq!(
1647            served.diff.pr_fetched_at,
1648            Some(fetched_at),
1649            "the stored attempt time stands, so the loop still finds the cache stale",
1650        );
1651
1652        let fetched = workspace
1653            .scan_work_if_stale(&seat(), true, PrRefresh::Fetch)
1654            .await
1655            .expect("a live seat reads its tree");
1656        assert!(branch.marker.exists(), "the loop's policy does reach the lookup");
1657        assert_eq!(
1658            fetched.diff.pr.map(|found| found.number),
1659            Some(108),
1660            "and the shim's answer is what the scan then holds",
1661        );
1662    }
1663
1664    /// **The deferred lookup still reaches the viewer.** The record
1665    /// serves the PR its store holds and never waits; the seat's watch is
1666    /// where the lookup runs, and the row it moves is pushed - so the
1667    /// correction a reader sees is a frame, not a second read.
1668    #[tokio::test(flavor = "current_thread")]
1669    async fn the_watch_pushes_the_pr_a_record_served_from_its_store() {
1670        use forge_primitives::git::GitPrInfo;
1671
1672        let (branch, _shim) = PushedBranch::new();
1673        let (workspace, mut updates, _config) =
1674            a_workspace_with_env(branch.repo.path(), &branch.env());
1675        let seat = seat();
1676
1677        // The store's row held a PR, and the attempt that found it is long
1678        // past the refetch window: the lookup is due.
1679        let held =
1680            GitPrInfo { number: 7, url: "https://example.test/pr/7".to_owned(), draft: false };
1681        workspace.store_work_snapshot(
1682            &seat,
1683            branch.stored_scan(
1684                Some(held.clone()),
1685                std::time::SystemTime::now() - Duration::from_secs(600),
1686            ),
1687        );
1688
1689        // The subscribe, as the transport runs it: hold, read the record (which
1690        // serves the stored PR and starts no lookup), then mark the read so the
1691        // watch's first pass begins from these stores.
1692        workspace.hold_seat(&seat);
1693        let served = workspace
1694            .scan_work_if_stale(&seat, false, PrRefresh::ServeCached)
1695            .await
1696            .expect("a live seat reads its tree");
1697        assert_eq!(served.diff.pr, Some(held), "the record serves what the store held");
1698        assert!(!branch.marker.exists(), "and the read ran no lookup to answer");
1699        workspace.note_record_read(&seat);
1700
1701        // **The correction is the watch's next pass, not the tree's own
1702        // window.** The record's read left the store fresh, so a loop that
1703        // only scanned on tree staleness would take a whole `SNAPSHOT_STALENESS`
1704        // to push; the bound is derived from that window so the two cannot
1705        // drift apart, and the PR-due leg is what keeps this inside a poke.
1706        let deadline =
1707            tokio::time::Instant::now() + SNAPSHOT_STALENESS.saturating_sub(Duration::from_secs(2));
1708        loop {
1709            let Ok(Some(moved)) = tokio::time::timeout_at(deadline, updates.recv()).await else {
1710                panic!("the watch did not push the correction its lookup made within a poke");
1711            };
1712            let SessionUpdate::WorkChanged { key, pr, .. } =
1713                std::sync::Arc::unwrap_or_clone(moved.update)
1714            else {
1715                continue;
1716            };
1717            assert_eq!(key, seat, "and the row is addressed to the seat that was held");
1718            assert_eq!(
1719                pr.map(|found| found.number),
1720                Some(108),
1721                "the pushed row carries the PR the deferred lookup found",
1722            );
1723            return;
1724        }
1725    }
1726
1727    /// **The CLI's repository-mutation report is the one signal the file watch
1728    /// cannot produce.** A commit moves only the git dir, which the fixture
1729    /// keeps outside the tree, so nothing watches it and the row keeps drawing
1730    /// the tree it read before until `SNAPSHOT_STALENESS` expires - unless the
1731    /// frame re-reads it. The store assertions below stand in for a quiet
1732    /// window: they say the loop's own rules are not due at the moment of the
1733    /// nudge, so the wake that follows is the only thing that can read.
1734    #[tokio::test]
1735    async fn a_reported_mutation_re_reads_a_git_only_change_on_the_frame() {
1736        let (branch, _shim) = PushedBranch::new_with_git_dir_outside_the_tree();
1737        let (workspace, mut updates, _config) =
1738            a_workspace_with_env(branch.repo.path(), &branch.env());
1739        let seat = seat();
1740
1741        hold_for_a_page(&workspace, &seat);
1742        announced_work(&mut updates).await;
1743
1744        // A commit on the branch the row measures: only the git dir moves, and
1745        // the fixture keeps it outside the tree, so no backend can report it.
1746        git(branch.repo.path(), &["commit", "--allow-empty", "-qm", "second"]);
1747
1748        // **The store is asserted, not a wall clock.** A negative window proved
1749        // nothing a slower runner could not overturn - it said no poke happened
1750        // to fire, not that the nudge did. At the moment of the nudge the store
1751        // must still hold the pre-commit row, its read too young for the
1752        // staleness rule and its PR attempt inside its own window: the loop's
1753        // own rules then cannot read the tree for the next several seconds, so
1754        // the re-read inside the bound below can only be the nudge.
1755        let held = workspace.work_snapshot(&seat).expect("the first pass stored a scan");
1756        assert!(
1757            !matches!(held.diff.branch_ahead, LayerState::Populated(_)),
1758            "nothing has re-read the tree since the commit",
1759        );
1760        assert!(
1761            held.read_at.elapsed() < SNAPSHOT_STALENESS,
1762            "and the read is too young for the tree's own rule to fire",
1763        );
1764        assert!(
1765            held.diff
1766                .pr_fetched_at
1767                .is_some_and(|at| at.elapsed().is_ok_and(|age| age < Duration::from_secs(300))),
1768            "nor is the PR attempt due",
1769        );
1770
1771        // The nudge arrives as the frame does, through the task's own fold:
1772        // the arm that reads it and the wake it fires are pinned together,
1773        // which a direct `note_repository_activity` call cannot do.
1774        let (handle, _agent_cmds) = forge_agent::Agent::testing_stub();
1775        let (_cmd_tx, command_rx) = tokio::sync::mpsc::unbounded_channel();
1776        let domain = workspace.domain_session_for(&seat).expect("the seat holds a session");
1777        let mut task = crate::session_task::SessionTask {
1778            key: seat.clone(),
1779            handle: Arc::new(handle),
1780            command_rx,
1781            domain,
1782            update_tx: workspace.update_sender(),
1783            connected_once: false,
1784            workspace: Arc::downgrade(&workspace),
1785            compaction_count: 0,
1786            successor: None,
1787            teardown_done: None,
1788        };
1789        task.translate_event(forge_agent::AgentEvent::SdkMessage {
1790            session_id: "s".to_owned(),
1791            msg: forge_primitives::Message::VcsStateChanged {
1792                kind: "commit".to_owned(),
1793                branch: Some("feature".to_owned()),
1794                cwd: branch.repo.path().display().to_string(),
1795                uuid: "u1".to_owned(),
1796                session_id: "s".to_owned(),
1797                extras: forge_primitives::messages::Extras::new(),
1798            },
1799        });
1800
1801        // The store above is fresh, so the tree's own rule is not due for
1802        // `SNAPSHOT_STALENESS`; a bound inside that is still only the nudge's
1803        // to meet, with room for a scan on a loaded machine.
1804        let deadline = tokio::time::Instant::now() + SNAPSHOT_STALENESS - Duration::from_secs(4);
1805        loop {
1806            let Ok(Some(moved)) = tokio::time::timeout_at(deadline, updates.recv()).await else {
1807                panic!("the reported mutation did not re-read the tree");
1808            };
1809            let SessionUpdate::WorkChanged { key, git, .. } =
1810                std::sync::Arc::unwrap_or_clone(moved.update)
1811            else {
1812                continue;
1813            };
1814            assert_eq!(key, seat, "the row is addressed to the seat that was held");
1815            assert_eq!(
1816                git.ahead.map(|ahead| ahead.commit_count),
1817                Some(1),
1818                "the tree is read on the frame, inside the staleness window",
1819            );
1820            return;
1821        }
1822    }
1823
1824    /// **A published change is exactly what a held PR block predates.** The
1825    /// lookup that found no PR is still inside `PR_REFETCH_INTERVAL`, and the
1826    /// cache would serve it for the rest of the window; the nudge drops the
1827    /// attempt stamp so the next scan looks again.
1828    #[tokio::test]
1829    async fn a_published_change_looks_the_pr_up_again_inside_its_cache_window() {
1830        let (branch, _shim) = PushedBranch::new();
1831        let (workspace, mut updates, _config) =
1832            a_workspace_with_env(branch.repo.path(), &branch.env());
1833        let seat = seat();
1834
1835        workspace
1836            .store_work_snapshot(&seat, branch.stored_scan(None, std::time::SystemTime::now()));
1837
1838        // The stored read is past its window, so the first pass scans. Wait on
1839        // the store it writes rather than on a clock: the scan landing is the
1840        // condition, and a loaded machine only makes it slower.
1841        let seeded_read =
1842            workspace.work_snapshot(&seat).expect("the fixture stored a scan").read_at;
1843        hold_for_a_page(&workspace, &seat);
1844        wait_until(Duration::from_secs(15), || {
1845            workspace.work_snapshot(&seat).is_some_and(|held| held.read_at > seeded_read)
1846        })
1847        .await;
1848        assert!(!branch.marker.exists(), "a fresh PR block is served, not looked up again");
1849
1850        workspace.note_code_change_published(&seat);
1851
1852        // **The bound only says the lookup landed, not that the wake fired.**
1853        // The cleared stamp makes `pr_refresh_due` true, so the ordinary poke
1854        // would look the PR up too; the wake itself is pinned by
1855        // `a_nudge_wakes_a_held_seat`, which needs no clock. This is wide
1856        // enough for the lookup on a loaded machine and still catches a path
1857        // that never runs it at all.
1858        let deadline = tokio::time::Instant::now() + SNAPSHOT_STALENESS - Duration::from_secs(4);
1859        loop {
1860            let Ok(Some(moved)) = tokio::time::timeout_at(deadline, updates.recv()).await else {
1861                panic!("the published change did not re-run the PR lookup");
1862            };
1863            let SessionUpdate::WorkChanged { pr, .. } =
1864                std::sync::Arc::unwrap_or_clone(moved.update)
1865            else {
1866                continue;
1867            };
1868            assert_eq!(
1869                pr.map(|found| found.number),
1870                Some(108),
1871                "the row carries the PR that exists now, not the cached absence",
1872            );
1873            return;
1874        }
1875    }
1876
1877    /// **The wake itself, with no clock in it.** The published-change test
1878    /// pins the stamp clear, but a cleared stamp leaves `pr_refresh_due` true,
1879    /// so the ordinary poke would look the PR up anyway and that test would
1880    /// pass with no wake at all. Only this says the `notify_one` is there:
1881    /// nothing else can complete the future, so the short bound is sound.
1882    #[tokio::test]
1883    async fn a_nudge_wakes_a_held_seat_and_marks_only_a_published_one_stale() {
1884        let seats = HeldSeats::default();
1885        let slot = seat();
1886        let taken = seats.acquire(&slot);
1887
1888        seats.nudge(&slot, false);
1889        tokio::time::timeout(Duration::from_millis(50), taken.nudged.notified())
1890            .await
1891            .expect("a plain nudge wakes the seat's loop");
1892        assert!(!seats.take_pr_stale(&slot), "and asks for no PR lookup");
1893
1894        seats.nudge(&slot, true);
1895        tokio::time::timeout(Duration::from_millis(50), taken.nudged.notified())
1896            .await
1897            .expect("a published nudge wakes it too");
1898        assert!(seats.take_pr_stale(&slot), "carrying the PR refresh");
1899        assert!(!seats.take_pr_stale(&slot), "and the mark is spent once");
1900
1901        seats.release(&slot);
1902    }
1903
1904    /// **The stamp is dropped when the change is noted, not when a scan runs.**
1905    /// The stored snapshot outlives the hold, so a change published while
1906    /// nobody is showing the seat would otherwise leave `pr_fetched_at` in
1907    /// place and the next open would serve the cache this event exists to
1908    /// invalidate.
1909    #[tokio::test]
1910    async fn an_unheld_published_change_clears_the_attempt_stamp() {
1911        let (branch, _shim) = PushedBranch::new();
1912        let (workspace, _updates, _config) =
1913            a_workspace_with_env(branch.repo.path(), &branch.env());
1914        let seat = seat();
1915
1916        workspace
1917            .store_work_snapshot(&seat, branch.stored_scan(None, std::time::SystemTime::now()));
1918        assert!(
1919            workspace.work_snapshot(&seat).is_some_and(|held| held.diff.pr_fetched_at.is_some()),
1920            "the store holds a fresh attempt to invalidate",
1921        );
1922
1923        workspace.note_code_change_published(&seat);
1924
1925        assert!(
1926            workspace.work_snapshot(&seat).is_some_and(|held| held.diff.pr_fetched_at.is_none()),
1927            "a published change clears the attempt even with no seat held",
1928        );
1929    }
1930
1931    /// **A forced scan must not consult the cache at all.** Clearing the
1932    /// stamp is not enough by itself: a Fetch scan already in flight when the
1933    /// publish frame landed can store a fresh stamp after the clear, and a
1934    /// force that only trusted the clear would then serve the pre-publication
1935    /// answer - this feature's own defect, reopened in that window.
1936    #[tokio::test]
1937    async fn a_forced_scan_looks_the_pr_up_past_a_fresh_cache() {
1938        let (branch, _shim) = PushedBranch::new();
1939        let (workspace, _updates, _config) =
1940            a_workspace_with_env(branch.repo.path(), &branch.env());
1941        let seat = seat();
1942
1943        // The stored read AND its attempt are both fresh: nothing about the
1944        // store says a lookup is due.
1945        let mut fresh = branch.stored_scan(None, std::time::SystemTime::now());
1946        fresh.read_at = Instant::now();
1947        workspace.store_work_snapshot(&seat, fresh);
1948
1949        let scanned = workspace.scan_work_now(&seat, true).await.expect("a live seat scans");
1950
1951        assert!(branch.marker.exists(), "the forced pass ran the lookup");
1952        assert_eq!(
1953            scanned.diff.pr.map(|found| found.number),
1954            Some(108),
1955            "and answered with the PR the fresh cache denied",
1956        );
1957    }
1958
1959    /// …and the next open then looks the PR up rather than reusing the absence
1960    /// the cleared stamp belonged to.
1961    #[tokio::test]
1962    async fn the_next_open_after_an_unheld_published_change_looks_the_pr_up() {
1963        let (branch, _shim) = PushedBranch::new();
1964        let (workspace, mut updates, _config) =
1965            a_workspace_with_env(branch.repo.path(), &branch.env());
1966        let seat = seat();
1967
1968        workspace
1969            .store_work_snapshot(&seat, branch.stored_scan(None, std::time::SystemTime::now()));
1970        workspace.note_code_change_published(&seat);
1971        assert!(!branch.marker.exists(), "noting the change runs no lookup by itself");
1972
1973        hold_for_a_page(&workspace, &seat);
1974
1975        let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
1976        loop {
1977            let Ok(Some(moved)) = tokio::time::timeout_at(deadline, updates.recv()).await else {
1978                panic!("the next open served the cache the published change invalidated");
1979            };
1980            let SessionUpdate::WorkChanged { pr, .. } =
1981                std::sync::Arc::unwrap_or_clone(moved.update)
1982            else {
1983                continue;
1984            };
1985            assert_eq!(
1986                pr.map(|found| found.number),
1987                Some(108),
1988                "the open's scan looked the PR up again",
1989            );
1990            return;
1991        }
1992    }
1993
1994    /// **A hold answers before its scans do** (#2506). The open used to pay
1995    /// three sequential stale reads before it could answer; the watch the hold
1996    /// starts takes them, so a slow read cannot hold a page.
1997    #[tokio::test]
1998    async fn a_hold_is_served_before_its_scans_complete() {
1999        let dir = a_repo();
2000        let (workspace, _updates, _config) = a_workspace(dir.path());
2001        let seat = seat();
2002        let gate = Arc::new(tokio::sync::Semaphore::new(0));
2003        workspace.seed_test_scan_gate(Arc::clone(&gate));
2004
2005        // The hold returns with the watch spawned; the reads are at the gate.
2006        workspace.hold_seat(&seat);
2007
2008        assert!(workspace.work_snapshot(&seat).is_none(), "the tree's scan has not answered");
2009        assert!(workspace.process_snapshot(&seat).is_none(), "nor has the process walk");
2010        assert!(workspace.file_index(&seat).is_none(), "nor has the file index");
2011
2012        gate.add_permits(8);
2013    }
2014
2015    /// **The record's read and the first pass are one scan** (#2506 review).
2016    /// Both take the seat's gate, so a snapshot's fallback read and the loop's
2017    /// first pass cannot scan the same tree together: whichever gets there
2018    /// first stores, and the other answers that store.
2019    #[tokio::test]
2020    async fn one_scan_serves_the_records_read_and_the_first_pass() {
2021        let dir = a_repo();
2022        let (workspace, _updates, _config) = a_workspace(dir.path());
2023        let seat = seat();
2024        workspace.hold_seat(&seat);
2025        // The scan itself is held, so both reads are inside the seat's gate
2026        // when it opens.
2027        let gate = Arc::new(tokio::sync::Semaphore::new(0));
2028        workspace.seed_test_scan_gate(Arc::clone(&gate));
2029
2030        let read = |workspace: Arc<Workspace>, seat: SessionSlot| async move {
2031            workspace.scan_work_if_stale(&seat, false, PrRefresh::Fetch).await
2032        };
2033        let record = tokio::spawn(read(Arc::clone(&workspace), seat.clone()));
2034        let first_pass = tokio::spawn(read(Arc::clone(&workspace), seat.clone()));
2035        tokio::task::yield_now().await;
2036        gate.add_permits(8);
2037
2038        let record = record.await.expect("the record's read").expect("a live seat scans");
2039        let first_pass =
2040            first_pass.await.expect("the first pass's read").expect("a live seat scans");
2041        assert_eq!(
2042            record.read_at, first_pass.read_at,
2043            "one scan served both: the second answered the store the first wrote",
2044        );
2045    }
2046
2047    /// **A record read does not queue behind a scan somebody else is running.**
2048    /// The watch's Fetch scan holds the seat's gate across its PR lookup, so a
2049    /// second open (a phone beside the desktop, a re-subscribe) must answer the
2050    /// store it already holds rather than wait a network round trip it never
2051    /// asked for. The control below is the same read under the fetching policy,
2052    /// which is the one that waits - so the fast answer is the policy and not a
2053    /// gate nothing takes.
2054    #[tokio::test]
2055    async fn a_record_read_serves_the_store_while_a_scan_holds_the_gate() {
2056        let dir = a_repo();
2057        let (workspace, _updates, _config) = a_workspace(dir.path());
2058        let seat = seat();
2059        workspace.hold_seat(&seat);
2060        let stored = a_snapshot(Instant::now(), dir.path());
2061        workspace.store_work_snapshot(&seat, stored.clone());
2062
2063        // The scan in flight: the seat's own gate, held for as long as this
2064        // test keeps the guard.
2065        let gate = workspace.held_work_seats.gate(&seat).expect("a held seat has a gate");
2066        let _in_flight = gate.lock().await;
2067
2068        let served = tokio::time::timeout(
2069            Duration::from_millis(500),
2070            workspace.scan_work_if_stale(&seat, false, PrRefresh::ServeCached),
2071        )
2072        .await
2073        .expect("a read that must not wait answers at once")
2074        .expect("the store is there");
2075        assert_eq!(served.read_at, stored.read_at, "and it answers the store's own scan");
2076
2077        assert!(
2078            tokio::time::timeout(
2079                Duration::from_millis(500),
2080                workspace.scan_work_if_stale(&seat, true, PrRefresh::Fetch),
2081            )
2082            .await
2083            .is_err(),
2084            "a fetching scan is the one that queues on the gate in flight",
2085        );
2086    }
2087
2088    /// **A drawing read waits out a scan in flight and then reads the tree.**
2089    /// The gate is the one the watch's scan holds, so a review read that took
2090    /// the store instead would draw a scan written before the base it just
2091    /// refreshed moved. The control is the store: it says "not a repo", which
2092    /// is what a read that did not wait would serve.
2093    #[tokio::test]
2094    async fn a_pinned_scan_waits_out_a_scan_in_flight_and_answers_a_fresh_one() {
2095        let dir = a_repo();
2096        let (workspace, _updates, _config) = a_workspace(dir.path());
2097        let seat = seat();
2098        workspace.hold_seat(&seat);
2099        workspace.store_work_snapshot(&seat, a_snapshot(Instant::now(), dir.path()));
2100        let gate = workspace.held_work_seats.gate(&seat).expect("a held seat has a gate");
2101        let in_flight = gate.lock().await;
2102
2103        let scanning = Arc::clone(&workspace);
2104        let at = dir.path().to_path_buf();
2105        let seat_for_task = seat.clone();
2106        let waiting = tokio::spawn(async move {
2107            scanning.scan_work_pinned(&seat_for_task, &at, None, None).await
2108        });
2109        tokio::time::sleep(Duration::from_millis(100)).await;
2110        assert!(!waiting.is_finished(), "the drawing read queues on the scan in flight");
2111
2112        drop(in_flight);
2113        let fresh =
2114            waiting.await.expect("the task joins").expect("the gate frees and the scan runs");
2115        assert_eq!(
2116            fresh.diff.repo_gate,
2117            RepoGate::InRepo,
2118            "the answer is the scan taken after the gate, not the stored non-repo",
2119        );
2120    }
2121
2122    /// **The wait is bounded.** A gate held past `PINNED_GATE_WAIT` - a watch
2123    /// pass stuck on its own network work - answers the read with a refusal,
2124    /// so the page serves its store and labels the base unverified rather
2125    /// than spending the whole open on a scan it never asked for.
2126    #[tokio::test]
2127    async fn a_pinned_scan_gives_up_on_a_gate_held_past_its_bound() {
2128        // The bound is real time; the test's is short, and restored after.
2129        set_pinned_gate_wait(100);
2130        let dir = a_repo();
2131        let (workspace, _updates, _config) = a_workspace(dir.path());
2132        let seat = seat();
2133        workspace.hold_seat(&seat);
2134        let gate = workspace.held_work_seats.gate(&seat).expect("a held seat has a gate");
2135        let in_flight = gate.lock().await;
2136
2137        let started = Instant::now();
2138        let refused = workspace.scan_work_pinned(&seat, dir.path(), None, None).await;
2139        assert!(refused.is_err(), "a held gate past the bound refuses the read");
2140        assert!(
2141            started.elapsed() >= Duration::from_millis(100),
2142            "and the read waits its bound first, not a moment less",
2143        );
2144
2145        drop(in_flight);
2146        assert!(
2147            workspace.scan_work_pinned(&seat, dir.path(), None, None).await.is_ok(),
2148            "a free gate scans at once",
2149        );
2150        set_pinned_gate_wait(0);
2151    }
2152
2153    /// **The watch's measurement rides the row.** A base can move without the
2154    /// count or the chain moving, so the state the watch refreshed is carried
2155    /// on the snapshot - and a scan that did not refresh keeps the last one
2156    /// rather than erasing it.
2157    #[tokio::test]
2158    async fn the_watchs_measured_base_rides_the_row() {
2159        let dir = a_repo();
2160        let (workspace, _updates, _config) = a_workspace(dir.path());
2161        let seat = seat();
2162        workspace.store_work_snapshot(&seat, a_snapshot(Instant::now(), dir.path()));
2163
2164        let measured = workspace
2165            .scan_work_at_with_base(
2166                &seat,
2167                dir.path(),
2168                PrRefresh::ServeCached,
2169                None,
2170                Some(forge_agent::env::git_diff::BaseMeasurement {
2171                    at: Instant::now(),
2172                    state: BaseState::Behind { behind: 2 },
2173                    error: None,
2174                }),
2175                false,
2176                false,
2177            )
2178            .await;
2179        assert_eq!(measured.base, Some(BaseState::Behind { behind: 2 }), "the measurement lands");
2180
2181        let inherited = workspace.scan_work_at(&seat, dir.path(), PrRefresh::ServeCached).await;
2182        assert_eq!(
2183            inherited.base,
2184            Some(BaseState::Behind { behind: 2 }),
2185            "and a later local scan keeps it instead of erasing it",
2186        );
2187        assert_eq!(
2188            git_work_view(&inherited.diff, inherited.base, inherited.base_error.as_deref()).base,
2189            Some(BaseState::Behind { behind: 2 }),
2190            "so the row a view draws says what the base is",
2191        );
2192    }
2193
2194    /// A checkout on `feature` with a real `origin` whose `origin/main` lags
2195    /// the remote: the shape a scan's background kick exists to refresh.
2196    fn a_stale_remote_checkout() -> (tempfile::TempDir, tempfile::TempDir) {
2197        let upstream = tempfile::tempdir().expect("a bare remote");
2198        git(upstream.path(), &["init", "-q", "--bare"]);
2199        let dir = a_repo();
2200        let repo = dir.path();
2201        git(repo, &["remote", "add", "origin", &upstream.path().to_string_lossy()]);
2202        git(repo, &["push", "-q", "-u", "origin", "main"]);
2203        let base = rev_parse(repo, "HEAD");
2204        std::fs::write(repo.join("upstream.txt"), "upstream").expect("write");
2205        git(repo, &["add", "."]);
2206        git(repo, &["commit", "-qm", "upstream"]);
2207        git(repo, &["push", "-q", "origin", "main"]);
2208        git(repo, &["checkout", "-q", "-b", "feature"]);
2209        std::fs::write(repo.join("feat.txt"), "feat").expect("write");
2210        git(repo, &["add", "."]);
2211        git(repo, &["commit", "-qm", "feat"]);
2212        // The tracking ref lags the remote, so the scan's kick has work to do.
2213        git(repo, &["update-ref", "refs/remotes/origin/main", &base]);
2214        git(repo, &["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"]);
2215        (upstream, dir)
2216    }
2217
2218    /// **A scan kicks its base refresh and never waits on it.** The state the
2219    /// kicked fetch measured is read from the checkout's last completed
2220    /// refresh, so a base-only move still reaches the row a later scan pushes.
2221    #[tokio::test]
2222    async fn a_kicked_refresh_reaches_the_next_scans_row() {
2223        let (upstream, dir) = a_stale_remote_checkout();
2224        let repo = dir.path();
2225
2226        let (workspace, _updates, _config) = a_workspace(repo);
2227        let seat = seat();
2228        let _first = workspace
2229            .scan_work_if_stale(&seat, false, PrRefresh::Fetch)
2230            .await
2231            .expect("the seat reads its tree");
2232
2233        // The wait is for the kicked fetch to finish; generous because a
2234        // loaded machine schedules the task late and the assertion below says
2235        // whether it ever ran.
2236        let mut waited = Duration::ZERO;
2237        while forge_agent::env::git_diff::last_base_measurement(repo, "origin/main").is_none()
2238            && waited < KICK_SETTLE
2239        {
2240            tokio::time::sleep(Duration::from_millis(20)).await;
2241            waited += Duration::from_millis(20);
2242        }
2243        assert_eq!(
2244            forge_agent::env::git_diff::last_base_measurement(repo, "origin/main")
2245                .map(|measured| measured.state),
2246            Some(BaseState::Current),
2247            "the kicked fetch measured a current base",
2248        );
2249
2250        let next = workspace
2251            .scan_work_if_stale(&seat, true, PrRefresh::Fetch)
2252            .await
2253            .expect("the moved tree is read");
2254        assert_eq!(
2255            next.base,
2256            Some(BaseState::Current),
2257            "and the next scan's row carries what the kick measured",
2258        );
2259        assert_eq!(
2260            rev_parse(repo, "refs/remotes/origin/main"),
2261            rev_parse(upstream.path(), "main"),
2262            "because the kick moved the ref the row describes",
2263        );
2264    }
2265
2266    /// **A completed base refresh reaches the row without the tree moving or
2267    /// its snapshot going stale.** The scan that carries the measurement is
2268    /// triggered by the measurement itself: a page drawing the row hears the
2269    /// base land while the tree it describes has not changed.
2270    #[tokio::test]
2271    async fn a_completed_base_refresh_is_scanned_at_once() {
2272        let (_upstream, dir) = a_stale_remote_checkout();
2273        let repo = dir.path();
2274        let (workspace, _updates, _config) = a_workspace(repo);
2275        let seat = seat();
2276        let _first = workspace
2277            .scan_work_if_stale(&seat, false, PrRefresh::Fetch)
2278            .await
2279            .expect("the seat reads its tree");
2280
2281        let mut waited = Duration::ZERO;
2282        while forge_agent::env::git_diff::last_base_measurement(repo, "origin/main").is_none()
2283            && waited < KICK_SETTLE
2284        {
2285            tokio::time::sleep(Duration::from_millis(20)).await;
2286            waited += Duration::from_millis(20);
2287        }
2288        assert_eq!(
2289            forge_agent::env::git_diff::last_base_measurement(repo, "origin/main")
2290                .map(|measured| measured.state),
2291            Some(BaseState::Current),
2292            "the kicked fetch measured a current base",
2293        );
2294
2295        // **Nothing else may ask for this scan.** The tree is clean and the
2296        // stored snapshot is stamped read now, and the PR attempt is stamped
2297        // now so its own due-window cannot be what triggers the read; only the
2298        // base landing is left. The read stamp matters under load: a poll that
2299        // outlasted the ten-second window would let a stale row trigger the
2300        // scan by time alone, and the mutation this test exists for survives.
2301        let mut stored = workspace.work_snapshot(&seat).expect("the first scan stored a row");
2302        stored.diff.pr_fetched_at = Some(std::time::SystemTime::now());
2303        stored.read_at = Instant::now();
2304        // **The row is the one a page drew before the check landed.** The
2305        // first scan may have carried the measurement already when the local
2306        // fetch beat its own tree walk; clearing it is what leaves the base
2307        // landing as the only thing left to trigger the next scan.
2308        stored.base = None;
2309        stored.base_at = None;
2310        stored.base_error = None;
2311        workspace.store_work_snapshot(&seat, stored);
2312
2313        let next = workspace
2314            .scan_work_if_stale(&seat, false, PrRefresh::Fetch)
2315            .await
2316            .expect("the seat reads its tree");
2317        assert_eq!(
2318            next.base,
2319            Some(BaseState::Current),
2320            "the completed refresh alone makes the row scan and carry it",
2321        );
2322        let measured = forge_agent::env::git_diff::last_base_measurement(repo, "origin/main")
2323            .expect("the measurement that landed");
2324        assert_eq!(
2325            next.base_at,
2326            Some(measured.at),
2327            "and the row carries the instant the measurement was taken, not when the row was stored",
2328        );
2329    }
2330
2331    /// **The scan's own measurement is one established before it began.** A
2332    /// check that lands while the tree is being read describes a ref the row's
2333    /// count and files may predate, so the next pass carries it; a caller's
2334    /// own answer is never held back.
2335    #[test]
2336    fn a_scan_adopts_only_a_measurement_from_before_it_began() {
2337        let started = Instant::now();
2338        let measurement = |at: Instant| forge_agent::env::git_diff::BaseMeasurement {
2339            at,
2340            state: BaseState::Current,
2341            error: None,
2342        };
2343
2344        assert_eq!(
2345            adopted_measurement(None, Some(measurement(started)), started),
2346            Some(measurement(started)),
2347            "a measurement from before the scan is the scan's own",
2348        );
2349        assert_eq!(
2350            adopted_measurement(
2351                None,
2352                Some(measurement(started + Duration::from_millis(1))),
2353                started,
2354            ),
2355            None,
2356            "one that landed during the scan waits for the next pass",
2357        );
2358        let passed = measurement(started + Duration::from_secs(5));
2359        assert_eq!(
2360            adopted_measurement(Some(passed.clone()), None, started),
2361            Some(passed),
2362            "and a caller's own answer is never filtered",
2363        );
2364    }
2365
2366    /// **A measurement's words are news even when its state is not.** The row
2367    /// a viewer was told last is compared by value, and a check that failed
2368    /// with new words is what a page that drew the base unchecked needs to act
2369    /// on. A check that found exactly the same thing again is not news.
2370    #[test]
2371    fn a_measurement_with_new_words_is_news_at_the_same_base() {
2372        let now = Instant::now();
2373        let mut first = a_snapshot(now, Path::new("/tmp/nowhere"));
2374        first.base = Some(BaseState::Unverified);
2375        first.base_at = Some(now);
2376        first.base_error = None;
2377        let mut second = first.clone();
2378        second.base_error = Some("fatal: could not resolve host".to_owned());
2379
2380        assert_eq!(first.base, second.base, "the two rows state the same base");
2381        assert!(
2382            Announced::of(&first) != Announced::of(&second),
2383            "but the words a failed check left are news a waiting page can act on",
2384        );
2385
2386        let mut repeat = second.clone();
2387        repeat.base_at = Some(now + Duration::from_millis(1));
2388        assert_eq!(
2389            Announced::of(&second),
2390            Announced::of(&repeat),
2391            "a check that found the same thing again is not news",
2392        );
2393    }
2394
2395    /// **A measurement older than the base a snapshot carries is not news.**
2396    /// A drawing read that found the base unchecked writes that state with its
2397    /// own instant; a fetch that finished before the read must not overwrite
2398    /// it, or the page would be told a check it has already superseded.
2399    #[tokio::test]
2400    async fn an_older_measurement_does_not_overwrite_a_reads_own_state() {
2401        let dir = a_repo();
2402        let (workspace, _updates, _config) = a_workspace(dir.path());
2403        let seat = seat();
2404        let read_at = Instant::now();
2405        let mut stored = a_snapshot(read_at, dir.path());
2406        stored.base = Some(BaseState::Unverified);
2407        stored.base_at = Some(read_at);
2408        workspace.store_work_snapshot(&seat, stored);
2409
2410        let older = read_at
2411            .checked_sub(Duration::from_secs(60))
2412            .expect("the test clock is a minute past boot");
2413        let kept = workspace
2414            .scan_work_at_with_base(
2415                &seat,
2416                dir.path(),
2417                PrRefresh::ServeCached,
2418                None,
2419                Some(forge_agent::env::git_diff::BaseMeasurement {
2420                    at: older,
2421                    state: BaseState::Current,
2422                    error: None,
2423                }),
2424                false,
2425                false,
2426            )
2427            .await;
2428        assert_eq!(
2429            kept.base,
2430            Some(BaseState::Unverified),
2431            "a stale measurement leaves the read's own state alone",
2432        );
2433
2434        let newer = read_at + Duration::from_secs(60);
2435        let adopted = workspace
2436            .scan_work_at_with_base(
2437                &seat,
2438                dir.path(),
2439                PrRefresh::ServeCached,
2440                None,
2441                Some(forge_agent::env::git_diff::BaseMeasurement {
2442                    at: newer,
2443                    state: BaseState::Current,
2444                    error: None,
2445                }),
2446                false,
2447                false,
2448            )
2449            .await;
2450        assert_eq!(
2451            adopted.base,
2452            Some(BaseState::Current),
2453            "and a measurement taken after the read is the one the row carries",
2454        );
2455    }
2456
2457    /// **Only a fetching scan is forced by a due PR.** The read path's policy
2458    /// is to serve; a PR-due leg that ignored the policy would turn every
2459    /// record read on a due branch into a tree scan, which is the cost the
2460    /// serving policy exists to keep off the open.
2461    #[tokio::test]
2462    async fn a_due_pr_does_not_force_a_serving_read_to_scan() {
2463        let dir = a_repo();
2464        let (workspace, _updates, _config) = a_workspace(dir.path());
2465        let seat = seat();
2466        let read_at = Instant::now();
2467        let mut stored = a_snapshot(read_at, dir.path());
2468        // A feature branch past its refetch window: the PR is due.
2469        stored.diff.branch = GitBranch::Named("feature".to_owned());
2470        stored.diff.default_branch = Some("origin/main".to_owned());
2471        stored.diff.pr_fetched_at = Some(
2472            std::time::SystemTime::now()
2473                .checked_sub(Duration::from_secs(600))
2474                .expect("a wall clock at least ten minutes past boot"),
2475        );
2476        workspace.store_work_snapshot(&seat, stored.clone());
2477
2478        let served = workspace
2479            .scan_work_if_stale(&seat, false, PrRefresh::ServeCached)
2480            .await
2481            .expect("a live seat reads its tree");
2482        assert_eq!(
2483            served.read_at, stored.read_at,
2484            "a serving read answers the store even when the PR is due",
2485        );
2486
2487        let fetched = workspace
2488            .scan_work_if_stale(&seat, false, PrRefresh::Fetch)
2489            .await
2490            .expect("a live seat reads its tree");
2491        assert_ne!(
2492            fetched.read_at, stored.read_at,
2493            "a fetching scan is the one the due leg forces",
2494        );
2495    }
2496
2497    /// **The record's index and the first pass are one walk** (#2506 review),
2498    /// on the same gate: the snapshot's fallback walk and the loop's first
2499    /// pass answer the same index rather than walking the tree twice.
2500    #[tokio::test]
2501    async fn one_walk_serves_the_records_index_and_the_first_pass() {
2502        let dir = a_repo();
2503        let (workspace, _updates, _config) = a_workspace(dir.path());
2504        let seat = seat();
2505        workspace.hold_seat(&seat);
2506        let gate = Arc::new(tokio::sync::Semaphore::new(0));
2507        workspace.seed_test_scan_gate(Arc::clone(&gate));
2508
2509        let record = tokio::spawn({
2510            let workspace = Arc::clone(&workspace);
2511            let seat = seat.clone();
2512            async move { workspace.walk_files_for_record(&seat).await }
2513        });
2514        let first_pass = tokio::spawn({
2515            let workspace = Arc::clone(&workspace);
2516            let seat = seat.clone();
2517            async move { workspace.walk_files_for_record(&seat).await }
2518        });
2519        tokio::task::yield_now().await;
2520        gate.add_permits(8);
2521
2522        let record = record.await.expect("the record's walk");
2523        let first_pass = first_pass.await.expect("the first pass's walk");
2524        assert!(
2525            Arc::ptr_eq(&record, &first_pass),
2526            "one walk served both: the second answered the index the first stored",
2527        );
2528    }
2529
2530    /// **The reads the hold gave up still run and their answers reach the
2531    /// seat's viewers** (rule 25): the first pass of the watch reads the tree,
2532    /// walks the processes and the files, and announces each of them.
2533    #[tokio::test]
2534    async fn the_first_pass_announces_the_tree_the_processes_and_the_files() {
2535        let dir = a_repo();
2536        let (workspace, mut updates, _config) = a_workspace(dir.path());
2537        let seat = seat();
2538        // The process walk needs a seat pid; this test's own tree is one that
2539        // exists and needs no CLI behind it.
2540        workspace.seed_test_claude_pid(&seat, std::process::id());
2541        let gate = Arc::new(tokio::sync::Semaphore::new(0));
2542        workspace.seed_test_scan_gate(Arc::clone(&gate));
2543
2544        // The hold returns with the watch spawned; the reads are at the gate.
2545        let started = tokio::time::Instant::now();
2546        hold_for_a_page(&workspace, &seat);
2547        assert!(
2548            tokio::time::timeout(Duration::from_millis(400), updates.recv()).await.is_err(),
2549            "nothing is announced while the scans are still held",
2550        );
2551        // **The stores say the reads are at the gate, not merely slow**: with
2552        // the seam removed, both would have landed inside the window above.
2553        assert!(workspace.work_snapshot(&seat).is_none(), "the tree's scan is held at the gate");
2554        assert!(workspace.file_index(&seat).is_none(), "and the walk with it");
2555        gate.add_permits(8);
2556
2557        let (mut work, mut processes, mut files) = (false, false, false);
2558        let mut order = Vec::new();
2559        let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
2560        while !(work && processes && files) {
2561            let Ok(Some(moved)) = tokio::time::timeout_at(deadline, updates.recv()).await else {
2562                panic!(
2563                    "the first pass's answers never all arrived: tree {work}, processes {processes}, files {files}"
2564                );
2565            };
2566            match std::sync::Arc::unwrap_or_clone(moved.update) {
2567                SessionUpdate::WorkChanged { key, .. } => {
2568                    assert_eq!(key, seat, "the tree's row is addressed to the held seat");
2569                    work = true;
2570                    order.push("work");
2571                }
2572                SessionUpdate::ProcessesChanged { key, .. } => {
2573                    assert_eq!(key, seat, "the walk is addressed to the held seat");
2574                    processes = true;
2575                    order.push("processes");
2576                }
2577                SessionUpdate::FileIndexChanged { key, .. } => {
2578                    assert_eq!(key, seat, "the index is addressed to the held seat");
2579                    files = true;
2580                    order.push("files");
2581                }
2582                _ => {}
2583            }
2584        }
2585        assert_eq!(
2586            order.first(),
2587            Some(&"work"),
2588            "the row a reader watches is announced before the walks behind it",
2589        );
2590        // **The pass runs on the record's mark, not after a poke.** A pass
2591        // that waited the loop's interval would land past this bound.
2592        assert!(
2593            started.elapsed() < Duration::from_millis(950),
2594            "the first pass ran within one poke: {:?}",
2595            started.elapsed(),
2596        );
2597    }
2598
2599    /// **The hold's scan answers as an update, and every row the tree moves to
2600    /// after it does too.** The open no longer waits for the read (#2506), so
2601    /// the first pass is what tells the page what the tree holds.
2602    #[tokio::test]
2603    async fn a_held_seat_announces_its_row_and_every_row_it_moves_to() {
2604        let dir = a_repo();
2605        let (workspace, mut updates, _config) = a_workspace(dir.path());
2606        let seat = seat();
2607
2608        hold_for_a_page(&workspace, &seat);
2609
2610        let row = announced_work(&mut updates).await;
2611        assert_eq!(row.gate, Gate::InRepo, "a checkout is read as a repository");
2612        assert_eq!(row.changed, Some(0), "and a clean one as nothing changed");
2613
2614        // Edited in a loop, and read for less than the staleness window: the
2615        // watch arms on its own thread, so the first edit can land before
2616        // notify is listening - and a test that wrote once and waited would
2617        // pass on the 10s rule instead, proving nothing about the watch.
2618        for edit in 1..12 {
2619            std::fs::write(dir.path().join("kept.txt"), "x".repeat(edit)).expect("write");
2620            let Ok(Some(moved)) =
2621                tokio::time::timeout(Duration::from_millis(700), updates.recv()).await
2622            else {
2623                continue;
2624            };
2625            let SessionUpdate::WorkChanged { key, work, .. } =
2626                std::sync::Arc::unwrap_or_clone(moved.update)
2627            else {
2628                continue;
2629            };
2630            assert_eq!(key, seat, "and it is addressed to the seat that was held");
2631            assert_eq!(work.changed, Some(1), "with the count the edit gives");
2632            return;
2633        }
2634        panic!("an edit inside the staleness window never reached the seat's viewers");
2635    }
2636
2637    /// **A viewer that arrives before the seat's session does still gets the
2638    /// tree once it starts** (#1706).
2639    ///
2640    /// A viewer that nothing drives to RE-SUBSCRIBE is the one the old refusal
2641    /// stranded: most recovered by accident, because a session starting is a
2642    /// REPLACES frame and the reread around it takes the hold again (the live
2643    /// log's `work_watch_quiet / session_gone` is one of those later holds) -
2644    /// but a viewer whose seat simply starts (or stays asleep) under it had no
2645    /// watch, and its pushed rows stood still. The hold is unconditional, so
2646    /// the FIRST subscribe is the one that counts.
2647    #[tokio::test]
2648    async fn a_seat_held_before_its_session_starts_announces_the_row_once_it_does() {
2649        let dir = a_repo();
2650        let (workspace, mut updates, _config) = a_declared_project(dir.path());
2651        let seat = seat();
2652
2653        hold_for_a_page(&workspace, &seat);
2654        assert!(
2655            !workspace.held_work_seats.lock().is_empty(),
2656            "a sessionless seat is still held - the loop waits quietly for its session",
2657        );
2658        workspace.register_domain_session(seat.clone(), None);
2659
2660        for edit in 1..12 {
2661            std::fs::write(dir.path().join("kept.txt"), "x".repeat(edit)).expect("write");
2662            let Ok(Some(moved)) =
2663                tokio::time::timeout(Duration::from_millis(700), updates.recv()).await
2664            else {
2665                continue;
2666            };
2667            // Other variants ride the same stream - the file index announces
2668            // too - and the row under test is the work one.
2669            let SessionUpdate::WorkChanged { key, work, .. } =
2670                std::sync::Arc::unwrap_or_clone(moved.update)
2671            else {
2672                continue;
2673            };
2674            assert_eq!(key, seat, "and it is addressed to the seat that was held");
2675            assert_eq!(work.changed, Some(1), "with the count the edit gives");
2676            return;
2677        }
2678        panic!("a seat held before its session started never announced its row");
2679    }
2680
2681    /// The read the loop drives answers the tree's new state.
2682    #[tokio::test]
2683    async fn a_write_moves_the_row_the_read_answers() {
2684        let dir = a_repo();
2685        let (workspace, _updates, _config) = a_workspace(dir.path());
2686        let seat = seat();
2687        hold_for_a_page(&workspace, &seat);
2688
2689        // A TRACKED file: the row counts the worktree layer, which is
2690        // uncommitted edits against HEAD, and an untracked file is not one.
2691        std::fs::write(dir.path().join("kept.txt"), "two").expect("write");
2692        let held = workspace
2693            .scan_work_if_stale(&seat, true, PrRefresh::Fetch)
2694            .await
2695            .expect("the seat's tree is readable");
2696
2697        assert_eq!(
2698            work_from_scan(&held.diff, &held.cwd).changed,
2699            Some(1),
2700            "the scan answers the edit the tree picked up",
2701        );
2702    }
2703
2704    /// The commands a walk hands the OS scan: the running bash tasks, whose
2705    /// detached processes sit outside claude's tree, and not the agent tasks
2706    /// beside them, which the walk never adopts. A task whose card forge has
2707    /// not seen has no command and is left to the registry's own row.
2708    #[test]
2709    fn only_local_bash_tasks_hand_the_scan_a_command() {
2710        let tasks = vec![
2711            crate::BackgroundTask {
2712                task_id: "t1".to_owned(),
2713                is_backgrounded: true,
2714                task_type: "local_bash".to_owned(),
2715                description: "gh run watch".to_owned(),
2716                command: Some("gh run watch 123 --exit-status".to_owned()),
2717                tool_use_id: None,
2718            },
2719            // The agent task CARRIES a command: the hold that records one is
2720            // not scoped to a card's tool, so the type is the thing that keeps
2721            // it out of the walk.
2722            crate::BackgroundTask {
2723                task_id: "t2".to_owned(),
2724                is_backgrounded: true,
2725                task_type: "local_agent".to_owned(),
2726                description: "a sub-agent".to_owned(),
2727                command: Some("investigate".to_owned()),
2728                tool_use_id: None,
2729            },
2730            crate::BackgroundTask {
2731                task_id: "t3".to_owned(),
2732                is_backgrounded: true,
2733                task_type: "local_bash".to_owned(),
2734                description: "a bash whose card forge never saw".to_owned(),
2735                command: None,
2736                tool_use_id: None,
2737            },
2738        ];
2739
2740        assert_eq!(
2741            local_bash_commands(&tasks),
2742            vec!["gh run watch 123 --exit-status".to_owned()],
2743            "only a bash task's own command is handed to the walk",
2744        );
2745    }
2746
2747    /// The walk's window: nothing walked yet is
2748    /// walked, one inside the window is the answer rather than a reason to
2749    /// walk, and one past it is walked again and stored where views
2750    /// read it. A stamp ahead of the clock counts as inside, on `elapsed()`'s
2751    /// error branch, which is where `a_walk_another_writer_moved_is_announced`
2752    /// keeps its store.
2753    #[tokio::test]
2754    async fn a_walk_happens_only_once_the_window_has_passed() {
2755        let dir = a_repo();
2756        let (workspace, _updates, _config) = a_workspace(dir.path());
2757        let seat = seat();
2758        let pid = Some(std::process::id());
2759
2760        assert!(
2761            workspace.walk_processes_if_stale(&seat, pid).await,
2762            "nothing walked yet is walked"
2763        );
2764        let walked = workspace.process_snapshot(&seat).expect("the walk stored a snapshot");
2765
2766        assert!(
2767            !workspace.walk_processes_if_stale(&seat, pid).await,
2768            "a snapshot inside the window is the answer rather than a reason to walk",
2769        );
2770
2771        let long_ago = std::time::SystemTime::now()
2772            .checked_sub(Duration::from_secs(60))
2773            .expect("a minute ago");
2774        workspace.store_process_snapshot(
2775            &seat,
2776            Some(forge_agent::env::processes::ProcessSnapshot {
2777                processes: walked.processes.clone(),
2778                scanned_at: long_ago,
2779            }),
2780        );
2781        assert!(
2782            workspace.walk_processes_if_stale(&seat, pid).await,
2783            "a snapshot past the window is walked again",
2784        );
2785        assert!(
2786            workspace.process_snapshot(&seat).is_some_and(|held| held.scanned_at > long_ago),
2787            "and stored where the views read it",
2788        );
2789
2790        // A stamp still ahead is not "not known fresh": classifying it as past
2791        // the window would walk, and replace the store the writer's fixture
2792        // keeps.
2793        let ahead = std::time::SystemTime::now() + SCAN_STALENESS;
2794        workspace.store_process_snapshot(
2795            &seat,
2796            Some(forge_agent::env::processes::ProcessSnapshot {
2797                processes: walked.processes.clone(),
2798                scanned_at: ahead,
2799            }),
2800        );
2801        assert!(
2802            !workspace.walk_processes_if_stale(&seat, pid).await,
2803            "a snapshot stamped ahead of the clock is inside the window, not past it",
2804        );
2805        assert_eq!(
2806            workspace.process_snapshot(&seat).map(|held| held.scanned_at),
2807            Some(ahead),
2808            "and the walk it did not take left that stamp in the store",
2809        );
2810    }
2811
2812    /// A seat with no process to walk is not walked, and keeps the snapshot
2813    /// it had rather than having an empty walk put in its place: an invented
2814    /// empty snapshot would draw as `no processes` where the truth is
2815    /// `nothing known`.
2816    #[tokio::test]
2817    async fn a_seat_with_no_process_to_walk_keeps_what_it_had() {
2818        let dir = a_repo();
2819        let (workspace, _updates, _config) = a_workspace(dir.path());
2820        let seat = seat();
2821        let held = std::time::SystemTime::now();
2822        workspace.store_process_snapshot(
2823            &seat,
2824            Some(forge_agent::env::processes::ProcessSnapshot {
2825                processes: Vec::new(),
2826                scanned_at: held,
2827            }),
2828        );
2829
2830        assert!(!workspace.walk_processes_if_stale(&seat, None).await, "nothing is walked");
2831        assert_eq!(
2832            workspace.process_snapshot(&seat).map(|snapshot| snapshot.scanned_at),
2833            Some(held),
2834            "and the snapshot it had is kept rather than replaced",
2835        );
2836    }
2837
2838    /// One process as a walk reports it, for a fixture that only needs an entry
2839    /// that is there.
2840    fn entry_at(pid: u32) -> forge_agent::env::processes::ProcessEntry {
2841        forge_agent::env::processes::ProcessEntry {
2842            pid,
2843            parent_pid: 1,
2844            name: "claude".to_owned(),
2845            command: "claude".to_owned(),
2846            memory_bytes: 1,
2847        }
2848    }
2849
2850    /// A walk of `entries`, taken now.
2851    fn walk_of(
2852        entries: Vec<forge_agent::env::processes::ProcessEntry>,
2853    ) -> forge_agent::env::processes::ProcessSnapshot {
2854        forge_agent::env::processes::ProcessSnapshot {
2855            processes: entries,
2856            scanned_at: std::time::SystemTime::now(),
2857        }
2858    }
2859
2860    /// A child process that goes when the test does, however it ends - the
2861    /// tree a walk is driven against in these cases.
2862    struct Child(std::process::Child);
2863
2864    impl Drop for Child {
2865        fn drop(&mut self) {
2866            let _ = self.0.kill();
2867            let _ = self.0.wait();
2868        }
2869    }
2870
2871    /// A walk carries news only when the tree moved, and the FIRST walk always
2872    /// does - `None` is nothing ever announced, not an empty tree.
2873    #[test]
2874    fn a_walk_carries_news_only_when_the_tree_moved() {
2875        assert!(walk_moved(None, &walk_of(Vec::new())), "the first walk is news whatever it found");
2876        assert!(walk_moved(None, &walk_of(vec![entry_at(1)])), "and so is a tree of one");
2877
2878        let told = vec![entry_at(1)];
2879        assert!(
2880            !walk_moved(Some(&told), &walk_of(vec![entry_at(1)])),
2881            "the same tree says nothing",
2882        );
2883        assert!(
2884            walk_moved(Some(&told), &walk_of(vec![entry_at(1), entry_at(2)])),
2885            "a process arriving is news",
2886        );
2887        assert!(walk_moved(Some(&told), &walk_of(Vec::new())), "and so is the tree emptying");
2888    }
2889
2890    /// **A walk somebody else moved reaches the seat's viewers.** Another
2891    /// writer can put a walk in this same store, and while the store is
2892    /// fresh the loop's own rule walks nothing - so a loop that read the
2893    /// store only when its own walk ran would leave every client's section
2894    /// frozen.
2895    #[tokio::test]
2896    async fn a_walk_another_writer_moved_is_announced() {
2897        let dir = a_repo();
2898        let (workspace, mut updates, _config) = a_workspace(dir.path());
2899        let seat = seat();
2900        // The seat's own pid, so the first pass walks a real tree.
2901        workspace.seed_test_claude_pid(&seat, std::process::id());
2902        // A walk from before the page, stale so the first pass replaces it and
2903        // announces the replacement: that frame says the pass is past its
2904        // baseline.
2905        let mut stale = walk_of(vec![entry_at(1)]);
2906        stale.scanned_at = std::time::SystemTime::now()
2907            .checked_sub(Duration::from_secs(60))
2908            .expect("a minute ago");
2909        workspace.store_process_snapshot(&seat, Some(stale));
2910        hold_for_a_page(&workspace, &seat);
2911        let within = POKE_INTERVAL.saturating_mul(10);
2912        let deadline = tokio::time::Instant::now() + within;
2913        loop {
2914            let Ok(frame) = tokio::time::timeout_at(deadline, updates.recv()).await else {
2915                panic!("the walk loop never announced its first walk");
2916            };
2917            let Some(moved) = frame else {
2918                panic!("the seat's updates stopped before the first walk was announced");
2919            };
2920            if matches!(
2921                std::sync::Arc::unwrap_or_clone(moved.update),
2922                SessionUpdate::ProcessesChanged { .. }
2923            ) {
2924                break;
2925            }
2926        }
2927
2928        // A walk another writer made, writing the store the pass holds a
2929        // baseline of. Its stamp sits past the loop's window, so no walk that
2930        // starts after this write replaces it; a staleness check that ever
2931        // treats a future stamp as stale puts this fixture back in that race.
2932        let mut moved_walk = walk_of(vec![entry_at(4242)]);
2933        moved_walk.scanned_at = std::time::SystemTime::now() + within;
2934        workspace.store_process_snapshot(&seat, Some(moved_walk));
2935
2936        let deadline = tokio::time::Instant::now() + within;
2937        loop {
2938            let Ok(frame) = tokio::time::timeout_at(deadline, updates.recv()).await else {
2939                panic!("the walk loop never announced the writer's walk");
2940            };
2941            let Some(moved) = frame else {
2942                panic!("the seat's updates stopped before the writer's walk was announced");
2943            };
2944            let SessionUpdate::ProcessesChanged { key, snapshot } =
2945                std::sync::Arc::unwrap_or_clone(moved.update)
2946            else {
2947                continue;
2948            };
2949            assert_eq!(key, seat, "and it is addressed to the seat that was held");
2950            assert_eq!(
2951                snapshot.processes,
2952                vec![entry_at(4242)],
2953                "with the walk the store holds, not one this loop took",
2954            );
2955            return;
2956        }
2957    }
2958
2959    /// **The seat's own tree reaches its viewers.** The walk is the loop's, so
2960    /// a held seat whose tree gains a process must see it; the two ways that
2961    /// breaks are the loop walking nothing and the announcement being dropped,
2962    /// and either leaves every client's section stale for good.
2963    ///
2964    /// The announcement is waited for BY THE CHILD rather than as the next one
2965    /// off the stream: these legs walk a real tree, and a running tree drifts
2966    /// (`walk_moved`), so a poke that finds only the drift is a legitimate
2967    /// frame that carries no child yet.
2968    #[tokio::test]
2969    async fn a_process_arriving_under_a_held_seat_is_announced() {
2970        let dir = a_repo();
2971        let (workspace, mut updates, _config) = a_workspace(dir.path());
2972        let seat = seat();
2973        // The pid the loop walks is this test's own, so a child spawned below
2974        // is a process under the seat's tree.
2975        workspace.seed_test_claude_pid(&seat, std::process::id());
2976        hold_for_a_page(&workspace, &seat);
2977
2978        let child =
2979            Child(std::process::Command::new("sleep").arg("30").spawn().expect("a child to find"));
2980        let child_pid = child.0.id();
2981
2982        let deadline = tokio::time::Instant::now() + Duration::from_secs(6);
2983        loop {
2984            let Ok(Some(moved)) = tokio::time::timeout_at(deadline, updates.recv()).await else {
2985                panic!("a process arriving under the seat was never announced");
2986            };
2987            let SessionUpdate::ProcessesChanged { key, snapshot } =
2988                std::sync::Arc::unwrap_or_clone(moved.update)
2989            else {
2990                continue;
2991            };
2992            assert_eq!(key, seat);
2993            if snapshot.processes.iter().any(|entry| entry.pid == child_pid) {
2994                break;
2995            }
2996        }
2997        drop(child);
2998    }
2999
3000    /// The watch's first walk, as the update that carries the hold's index
3001    /// now that the open no longer waits for it (#2506).
3002    async fn announced_index(
3003        updates: &mut tokio::sync::mpsc::UnboundedReceiver<SeatUpdate>,
3004    ) -> Arc<crate::file_index::FileIndex> {
3005        let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
3006        loop {
3007            let Ok(Some(moved)) = tokio::time::timeout_at(deadline, updates.recv()).await else {
3008                panic!("the watch's first walk was never announced");
3009            };
3010            if let SessionUpdate::FileIndexChanged { index, .. } =
3011                std::sync::Arc::unwrap_or_clone(moved.update)
3012            {
3013                return index;
3014            }
3015        }
3016    }
3017
3018    /// Wait until `condition` holds, panicking at `within`.
3019    ///
3020    /// **A condition, not a fixed sleep**: the poll returns the moment the
3021    /// store moves, so a loaded machine makes the wait slower rather than red.
3022    async fn wait_until(within: Duration, mut condition: impl FnMut() -> bool) {
3023        let deadline = tokio::time::Instant::now() + within;
3024        while !condition() {
3025            assert!(tokio::time::Instant::now() < deadline, "the condition never held");
3026            tokio::time::sleep(Duration::from_millis(10)).await;
3027        }
3028    }
3029
3030    /// The watch's first row, as the update that carries the hold's scan now
3031    /// that the open no longer waits for it (#2506).
3032    async fn announced_work(
3033        updates: &mut tokio::sync::mpsc::UnboundedReceiver<SeatUpdate>,
3034    ) -> WorkState {
3035        let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
3036        loop {
3037            let Ok(Some(moved)) = tokio::time::timeout_at(deadline, updates.recv()).await else {
3038                panic!("the watch's first scan was never announced");
3039            };
3040            if let SessionUpdate::WorkChanged { work, .. } =
3041                std::sync::Arc::unwrap_or_clone(moved.update)
3042            {
3043                return work;
3044            }
3045        }
3046    }
3047
3048    /// **The mark the watch sets is what makes the walk run inside the
3049    /// window.** A tree that moved is walked on the very next poke; one that
3050    /// did not waits for [`INDEX_STALENESS`] - and this drives the walk
3051    /// directly rather than racing the file watcher, whose promptness is the
3052    /// machine's business rather than this rule's.
3053    #[tokio::test]
3054    async fn the_watchs_mark_walks_inside_the_window() {
3055        let dir = a_repo();
3056        let (workspace, mut updates, _config) = a_workspace(dir.path());
3057        let seat = seat();
3058        hold_for_a_page(&workspace, &seat);
3059        announced_index(&mut updates).await;
3060        let held_at = workspace.file_index(&seat).expect("the first pass stores it").read_at;
3061
3062        assert!(
3063            !workspace.walk_files_if_stale(&seat, false).await,
3064            "a still tree inside the window is the answer rather than a reason to walk",
3065        );
3066
3067        std::fs::write(dir.path().join("new.rs"), "").expect("write");
3068        assert!(workspace.walk_files_if_stale(&seat, true).await, "the mark walks at once");
3069
3070        let walked = workspace.file_index(&seat).expect("the walk stored an index");
3071        assert!(walked.index.entries.contains_key("new.rs"), "with what the walk found");
3072        assert!(walked.read_at > held_at, "and the answer is the newer one");
3073    }
3074
3075    /// **A file written into a held seat's tree reaches its viewers.** The walk
3076    /// is the loop's, so a composer's `@` list follows the tree the person is
3077    /// working in.
3078    #[tokio::test]
3079    async fn a_file_written_into_a_held_seat_reaches_its_viewers() {
3080        let dir = a_repo();
3081        let (workspace, mut updates, _config) = a_workspace(dir.path());
3082        let seat = seat();
3083        hold_for_a_page(&workspace, &seat);
3084
3085        std::fs::write(dir.path().join("new.rs"), "").expect("write");
3086
3087        let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
3088        loop {
3089            let Ok(Some(moved)) = tokio::time::timeout_at(deadline, updates.recv()).await else {
3090                panic!("a file written into the seat's tree was never announced");
3091            };
3092            let SessionUpdate::FileIndexChanged { key, index } =
3093                std::sync::Arc::unwrap_or_clone(moved.update)
3094            else {
3095                continue;
3096            };
3097            assert_eq!(key, seat);
3098            if index.entries.contains_key("new.rs") {
3099                break;
3100            }
3101        }
3102    }
3103
3104    /// **Writes inside one poke arrive as ONE frame carrying all of them.**
3105    /// The watch's report is a mark, not a trigger: a walk per report would
3106    /// put one frame on the wire per file a build touches, which is the
3107    /// throttle a `@` list does not need and the socket will not merge.
3108    #[tokio::test]
3109    async fn writes_inside_one_poke_arrive_as_one_frame() {
3110        let dir = a_repo();
3111        let (workspace, mut updates, _config) = a_workspace(dir.path());
3112        let seat = seat();
3113        // The first pass is held so the writes land before the walk it takes,
3114        // making the writes' own frame the only index frame there is.
3115        let gate = Arc::new(tokio::sync::Semaphore::new(0));
3116        workspace.seed_test_scan_gate(Arc::clone(&gate));
3117        // The hold returns with the watch spawned; the reads are at the gate.
3118        hold_for_a_page(&workspace, &seat);
3119
3120        for name in ["a.rs", "b.rs", "c.rs"] {
3121            std::fs::write(dir.path().join(name), "").expect("write");
3122        }
3123        gate.add_permits(8);
3124
3125        let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
3126        let first = loop {
3127            let Ok(Some(moved)) = tokio::time::timeout_at(deadline, updates.recv()).await else {
3128                panic!("the writes were never announced");
3129            };
3130            if let SessionUpdate::FileIndexChanged { index, .. } =
3131                std::sync::Arc::unwrap_or_clone(moved.update)
3132            {
3133                break index;
3134            }
3135        };
3136        for name in ["a.rs", "b.rs", "c.rs"] {
3137            assert!(
3138                first.entries.contains_key(name),
3139                "one frame carries {name}: {:?}",
3140                first.entries.keys().collect::<Vec<_>>(),
3141            );
3142        }
3143
3144        // And nothing follows: one mark, one walk, one frame.
3145        let quiet = tokio::time::Instant::now() + Duration::from_secs(2);
3146        while let Ok(Some(moved)) = tokio::time::timeout_at(quiet, updates.recv()).await {
3147            if matches!(
3148                std::sync::Arc::unwrap_or_clone(moved.update),
3149                SessionUpdate::FileIndexChanged { .. }
3150            ) {
3151                panic!("a second frame followed one poke's writes");
3152            }
3153        }
3154    }
3155
3156    /// **Two walks that find the same index say nothing.** Unlike the process
3157    /// entries, a `FileCandidate` carries only paths and depths - no memory
3158    /// figure to drift as the tree works - so a still tree is genuinely still:
3159    /// the fixture repo runs no git maintenance, the one writer that moves
3160    /// `.git` under a tree nobody touched. The window is what makes this a
3161    /// walk: the poke at the far end of it takes one whether anything moved or
3162    /// not.
3163    #[tokio::test]
3164    async fn a_still_tree_announces_nothing() {
3165        let dir = a_repo();
3166        let (workspace, mut updates, _config) = a_workspace(dir.path());
3167        let seat = seat();
3168        hold_for_a_page(&workspace, &seat);
3169        // The first pass's own answer, drained so the window below opens after
3170        // it: the row is announced before the index in the same pass, so the
3171        // index's arrival says the pass is done.
3172        announced_work(&mut updates).await;
3173        let walked = announced_index(&mut updates).await;
3174        // The index the first pass left, so an announce names what moved rather
3175        // than counting it - triage is a read, not a re-derivation.
3176        let held = workspace.file_index(&seat).expect("the first pass stores the index");
3177        assert_eq!(held.index.entries.len(), walked.entries.len(), "and it is that index");
3178        let held_at = held.read_at;
3179
3180        // The wait is for the seat's own second walk, not for a clock: the
3181        // property is that two walks of a still tree agree, and a re-walk that
3182        // slipped past a fixed window would leave that unexercised - a quiet
3183        // channel would then prove nothing. Every update before it is still a
3184        // failure; none is due for a tree nobody touched.
3185        let deadline = Instant::now() + Duration::from_secs(30);
3186        while workspace.file_index(&seat).is_none_or(|seen| seen.read_at <= held_at) {
3187            assert!(
3188                Instant::now() < deadline,
3189                "the seat walks its index again inside the deadline"
3190            );
3191            match tokio::time::timeout(Duration::from_millis(50), updates.recv()).await {
3192                Ok(Some(update)) => announce_failure(&update.update, &held.index),
3193                Ok(None) => panic!("the updates channel closed"),
3194                Err(_) => {}
3195            }
3196        }
3197
3198        // And the spell past it, so the poke that took the walk is seen
3199        // through: the walk and the comparison are one body, and a frame this
3200        // tree never earned is due in neither.
3201        match tokio::time::timeout(Duration::from_secs(2), updates.recv()).await {
3202            Ok(Some(update)) => announce_failure(&update.update, &held.index),
3203            Ok(None) => panic!("the updates channel closed"),
3204            Err(_) => {}
3205        }
3206    }
3207
3208    /// The words a live announce fails with: the update itself, and for an
3209    /// index change what entered or left the index the hold left behind.
3210    fn announce_message(update: &SessionUpdate, held: &crate::file_index::FileIndex) -> String {
3211        let SessionUpdate::FileIndexChanged { index, .. } = update else {
3212            return format!("a still tree announced something: {update:?}");
3213        };
3214        format!(
3215            "a still tree announced an index change: {} entries after {} before, added {} removed {}",
3216            index.entries.len(),
3217            held.entries.len(),
3218            name_list(index.entries.keys().filter(|key| !held.entries.contains_key(key.as_str()))),
3219            name_list(held.entries.keys().filter(|key| !index.entries.contains_key(key.as_str()))),
3220        )
3221    }
3222
3223    /// A live announce on a still tree, named rather than counted.
3224    fn announce_failure(update: &SessionUpdate, held: &Arc<crate::file_index::FileIndex>) -> ! {
3225        panic!("{}", announce_message(update, held));
3226    }
3227
3228    /// Up to [`NAMES_SHOWN`] names, and the rest counted rather than trailing
3229    /// off where the reader cannot tell a short list from a truncated one.
3230    fn name_list(names: impl Iterator<Item = impl std::fmt::Display>) -> String {
3231        const NAMES_SHOWN: usize = 30;
3232        let all: Vec<String> = names.map(|name| name.to_string()).collect();
3233        if all.len() > NAMES_SHOWN {
3234            let rest = all.len() - NAMES_SHOWN;
3235            return format!("{:?} and {rest} more", &all[..NAMES_SHOWN]);
3236        }
3237        format!("{all:?}")
3238    }
3239
3240    /// **The announce names what moved**, which is what made this flake a
3241    /// read rather than a re-derivation, so the words are pinned.
3242    #[test]
3243    fn an_announce_names_what_moved() {
3244        let candidate = |rel: &str| crate::file_index::FileCandidate {
3245            rel_path: rel.to_owned(),
3246            rel_path_lower: rel.to_lowercase(),
3247            basename_lower: rel.to_lowercase(),
3248            depth: 0,
3249        };
3250        let held = crate::file_index::FileIndex {
3251            entries: [("gone.rs".to_owned(), candidate("gone.rs"))].into_iter().collect(),
3252        };
3253        let index = crate::file_index::FileIndex {
3254            entries: [("new.rs".to_owned(), candidate("new.rs"))].into_iter().collect(),
3255        };
3256        let message = announce_message(
3257            &SessionUpdate::FileIndexChanged { key: seat(), index: Arc::new(index) },
3258            &held,
3259        );
3260        assert!(message.contains("added [\"new.rs\"]"), "{message}");
3261        assert!(message.contains("removed [\"gone.rs\"]"), "{message}");
3262
3263        let mut long = crate::file_index::FileIndex::default();
3264        for nth in 0..31 {
3265            long.entries.insert(format!("f{nth}.rs"), candidate(&format!("f{nth}.rs")));
3266        }
3267        let capped = announce_message(
3268            &SessionUpdate::FileIndexChanged { key: seat(), index: Arc::new(long) },
3269            &held,
3270        );
3271        assert!(capped.contains("and 1 more"), "a capped list counts the rest: {capped}");
3272    }
3273
3274    /// A walk is an answer for the preference it was built under, and the
3275    /// seat's loop reads the preference per walk: a flip reaches the next one
3276    /// rather than waiting for a restart.
3277    #[tokio::test]
3278    async fn a_preference_flip_moves_the_next_walk() {
3279        let dir = a_repo();
3280        std::fs::write(dir.path().join(".gitignore"), "ignored.rs\n").expect("write");
3281        std::fs::write(dir.path().join("ignored.rs"), "").expect("write");
3282        let (workspace, mut updates, _config) = a_workspace(dir.path());
3283        let seat = seat();
3284        workspace.seed_test_user_preferences(serde_json::json!({}));
3285        hold_for_a_page(&workspace, &seat);
3286
3287        let held = announced_index(&mut updates).await;
3288        assert!(
3289            !held.entries.contains_key("ignored.rs"),
3290            "the walk respects the file while the preference says so",
3291        );
3292
3293        workspace.seed_test_user_preferences(serde_json::json!({"respectGitignore": false}));
3294        assert!(workspace.walk_files_if_stale(&seat, true).await, "a flip is a walk");
3295
3296        let walked = workspace.file_index(&seat).expect("the walk stored an index");
3297        assert!(
3298            walked.index.entries.contains_key("ignored.rs"),
3299            "and takes the preference read at the next walk",
3300        );
3301    }
3302
3303    /// **A watch stopped while it waited on the record's mark is noticed.**
3304    /// The release drops the stop sender, and a stop may fire beside the mark;
3305    /// either is a page that is no longer there, and the reads it was owed are
3306    /// not taken.
3307    #[test]
3308    fn a_watch_stopped_while_waiting_is_noticed_after_the_mark() {
3309        let (_open, mut waiting) = tokio::sync::oneshot::channel();
3310        assert!(!stopped_while_waiting(&mut waiting), "an open wait goes on");
3311
3312        let (dropped, mut released) = tokio::sync::oneshot::channel::<()>();
3313        drop(dropped);
3314        assert!(stopped_while_waiting(&mut released), "a released seat is noticed");
3315
3316        let (fired, mut stopped) = tokio::sync::oneshot::channel();
3317        fired.send(()).expect("the stop fires");
3318        assert!(stopped_while_waiting(&mut stopped), "and a fired stop is too");
3319    }
3320
3321    /// **A release and re-hold share the gate a read still holds.** The
3322    /// record's read can store after its page has gone; a re-held seat that
3323    /// made a second gate would let that store land beside the new read's.
3324    #[tokio::test]
3325    async fn a_release_and_re_hold_share_the_gate_a_read_holds() {
3326        let dir = a_repo();
3327        let (workspace, _updates, _config) = a_workspace(dir.path());
3328        let seat = seat();
3329        workspace.hold_seat(&seat);
3330        let in_flight = workspace.held_work_seats.gate(&seat).expect("a held seat has one");
3331
3332        workspace.release_seat(&seat);
3333        workspace.hold_seat(&seat);
3334        let reheld = workspace.held_work_seats.gate(&seat).expect("the seat is held again");
3335        assert!(
3336            Arc::ptr_eq(&in_flight, &reheld),
3337            "the gate outlived the hold while the read held it",
3338        );
3339
3340        // With the read and the new hold both gone, the gate goes too: a later
3341        // hold starts a fresh one rather than inheriting a dead seat's.
3342        drop(in_flight);
3343        drop(reheld);
3344        workspace.release_seat(&seat);
3345        assert!(
3346            workspace.held_work_seats.gate(&seat).is_none(),
3347            "no read and no hold leaves no gate",
3348        );
3349        assert!(workspace.held_work_seats.lock().is_empty(), "and the entry goes with the hold");
3350    }
3351
3352    /// **A viewer's own hold is released exactly once.** A sessionless seat
3353    /// is held like any other since #1706 - its loop waits for the session -
3354    /// so the release is counted per seat and the accounting is the whole
3355    /// property: every viewer that subscribed holds once and gives back
3356    /// exactly once, and one viewer's clean-up never spends another's count.
3357    #[tokio::test]
3358    async fn a_hold_is_released_exactly_once_per_viewer() {
3359        let dir = a_repo();
3360        let (workspace, _updates, _config) = a_workspace(dir.path());
3361        let seat = seat();
3362
3363        workspace.hold_seat(&seat);
3364        assert!(!workspace.held_work_seats.lock().is_empty(), "the first viewer holds");
3365
3366        // The session ends under the hold, and the next viewer still holds:
3367        // the watch waits for the session rather than refusing (#1706).
3368        workspace.release_session_with_cascade(&seat);
3369        workspace.hold_seat(&seat);
3370
3371        // Two taken holds: one release leaves the seat held, the second lets
3372        // it go.
3373        workspace.release_seat(&seat);
3374        assert!(
3375            !workspace.held_work_seats.lock().is_empty(),
3376            "one viewer is still holding the seat",
3377        );
3378        workspace.release_seat(&seat);
3379        assert!(
3380            workspace.held_work_seats.lock().is_empty(),
3381            "and the last release lets the seat go",
3382        );
3383    }
3384
3385    /// A session that ends under a held seat goes quiet, and the hold stands.
3386    ///
3387    /// The burn this prevents has a second entrance: the store rides the
3388    /// seat's record, so a seat whose session ended has nowhere to write, and
3389    /// the rule would read "nothing has scanned this" on every poke - a `gh`
3390    /// lookup apiece, announcing a tree for a seat that is not there. What
3391    /// must NOT happen is the seat being let go: the viewer is still showing
3392    /// it, and a session that comes back resumes being scanned under them.
3393    #[tokio::test]
3394    async fn a_session_ending_quiets_the_seat_without_letting_it_go() {
3395        let dir = a_repo();
3396        let (workspace, mut updates, _config) = a_workspace(dir.path());
3397        let seat = seat();
3398        hold_for_a_page(&workspace, &seat);
3399        assert!(!workspace.held_work_seats.lock().is_empty(), "the hold is taken");
3400        // The watch's first pass settles before the session goes, so the
3401        // release's own frame is the next one and the window after it is the
3402        // quiet the loop's guard owes.
3403        announced_work(&mut updates).await;
3404
3405        workspace.release_session_with_cascade(&seat);
3406
3407        // The release announces itself (#1930), and that frame is where this
3408        // window opens: what must NOT follow it is anything about the seat's
3409        // work.
3410        let released = tokio::time::timeout(Duration::from_secs(3), updates.recv())
3411            .await
3412            .expect("the release announces itself")
3413            .expect("a live channel");
3414        assert!(
3415            matches!(&*released.update, SessionUpdate::Releasing { .. }),
3416            "the release's own frame is what opens this window: {released:?}",
3417        );
3418
3419        // A move under the dead session says nothing: the rule refuses, so
3420        // nothing is scanned and nothing is announced.
3421        std::fs::write(dir.path().join("kept.txt"), "after the session").expect("write");
3422        assert!(
3423            tokio::time::timeout(Duration::from_secs(3), updates.recv()).await.is_err(),
3424            "a seat whose session ended must not announce anything",
3425        );
3426        assert!(
3427            !workspace.held_work_seats.lock().is_empty(),
3428            "and the hold stands, so the session coming back is scanned under it",
3429        );
3430
3431        // The session returns: the tree moves again, and this time it is told.
3432        workspace.register_domain_session(seat.clone(), None);
3433        let told = std::fs::write(dir.path().join("kept.txt"), "the session is back").is_ok();
3434        let announced = tokio::time::timeout(Duration::from_secs(3), updates.recv()).await.is_ok();
3435        assert!(
3436            announced,
3437            "a session that comes back under a held seat resumes being scanned (write ok: {told})",
3438        );
3439    }
3440
3441    /// **A sessionless seat is held, and its loop says nothing.** The hold
3442    /// waits for the session (#1706); the burn the old refusal guarded
3443    /// against is the quiet rules' business - no session means no record to
3444    /// store a scan in, so the loop waits instead of scanning once a second
3445    /// forever with a `gh` lookup apiece.
3446    ///
3447    /// **The mark starts the watch, and the guard is what keeps a sessionless
3448    /// seat quiet.** The read is marked here as a page's is, so the loop's
3449    /// first pass really runs and takes the sessionless branch - a plain hold
3450    /// would leave the watch waiting on its mark and prove nothing.
3451    ///
3452    /// The scan's own sessionless refusal is the second layer: a session can
3453    /// end between the guard and the read, and only the pair is observable -
3454    /// deleting either alone leaves this green.
3455    ///
3456    /// Reachable the moment a client subscribes to a declared-but-unstarted
3457    /// project, whose cwd resolves from the declaration alone - and that
3458    /// order is the everyday one for a client, so the hold has to be there
3459    /// when the session arrives.
3460    #[tokio::test]
3461    async fn a_sessionless_seat_is_held_and_says_nothing() {
3462        let dir = a_repo();
3463        let (workspace, mut updates, _config) = a_declared_project(dir.path());
3464        let seat = seat();
3465
3466        hold_for_a_page(&workspace, &seat);
3467
3468        assert!(workspace.work_snapshot(&seat).is_none(), "nothing was read for it");
3469        assert!(
3470            !workspace.held_work_seats.lock().is_empty(),
3471            "and the hold stands, waiting for the session",
3472        );
3473
3474        std::fs::write(dir.path().join("kept.txt"), "moved").expect("write");
3475        assert!(
3476            tokio::time::timeout(Duration::from_secs(2), updates.recv()).await.is_err(),
3477            "a sessionless seat's held loop must not announce anything",
3478        );
3479    }
3480
3481    /// A hold is counted: one viewer leaving does not stop the scan another
3482    /// still reads, and the last one lets the seat's loop go.
3483    #[tokio::test]
3484    async fn the_last_release_lets_the_seat_go() {
3485        let dir = a_repo();
3486        let (workspace, mut updates, _config) = a_workspace(dir.path());
3487        let seat = seat();
3488
3489        hold_for_a_page(&workspace, &seat);
3490        workspace.hold_seat(&seat);
3491        announced_work(&mut updates).await;
3492        assert!(workspace.work_snapshot(&seat).is_some(), "the watch reads the seat's tree");
3493
3494        workspace.release_seat(&seat);
3495        assert!(
3496            !workspace.held_work_seats.lock().is_empty(),
3497            "one viewer leaving keeps the seat held for the other",
3498        );
3499        workspace.release_seat(&seat);
3500        assert!(
3501            workspace.held_work_seats.lock().is_empty(),
3502            "and the last one lets the seat and its loop go",
3503        );
3504        assert!(workspace.work_snapshot(&seat).is_some(), "while what was read stays readable");
3505    }
3506
3507    /// The scan's `gh` spawn carries the identity of the seat's OWN
3508    /// project: a seat in one project must never read as another's, and a
3509    /// project that no longer resolves declares nothing, so `gh` falls
3510    /// back to its own login rather than to the server's environment.
3511    #[test]
3512    fn a_seats_scan_carries_its_own_projects_declared_env() {
3513        let dir = tempfile::tempdir().expect("tempdir");
3514        let forge = dir.path().join("forge");
3515        std::fs::create_dir_all(&forge).expect("forge dir");
3516        std::fs::write(
3517            forge.join("forge.toml"),
3518            r#"
3519[[orgs]]
3520name = "TestOrg"
3521accounts = ["acct-a"]
3522
3523[[orgs.projects]]
3524name = "alpha"
3525path = "/tmp/tp-alpha"
3526model = "claude-sonnet-5"
3527
3528[orgs.projects.env]
3529GH_TOKEN = "alpha-token"
3530
3531[[orgs.projects]]
3532name = "beta"
3533path = "/tmp/tp-beta"
3534model = "claude-sonnet-5"
3535
3536[orgs.projects.env]
3537GH_TOKEN = "beta-token"
3538
3539[[accounts]]
3540display_name = "acct-a"
3541token = "t"
3542models = ["claude-sonnet-5"]
3543provider = "anthropic"
3544"#,
3545        )
3546        .expect("write forge.toml");
3547        let workspace = Workspace::new_for_test(dir.path().to_owned()).expect("workspace");
3548
3549        assert_eq!(
3550            workspace.project_env("alpha").get("GH_TOKEN").map(String::as_str),
3551            Some("alpha-token"),
3552            "alpha's scan carries alpha's own token",
3553        );
3554        assert_eq!(
3555            workspace.project_env("beta").get("GH_TOKEN").map(String::as_str),
3556            Some("beta-token"),
3557            "and beta's carries beta's, not the first project's",
3558        );
3559        assert!(
3560            workspace.project_env("gone").is_empty(),
3561            "a project that no longer resolves declares none",
3562        );
3563    }
3564}