1use 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum Gate {
38 #[default]
41 InRepo,
42 NotARepository,
44 Gone,
50 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#[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
78pub 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
103pub 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#[derive(Clone)]
144pub struct WorkSnapshot {
145 pub diff: GitDiffSnapshot,
146 pub cwd: PathBuf,
150 pub read_at: Instant,
151 pub base: Option<BaseState>,
155 pub base_at: Option<Instant>,
162 pub base_error: Option<String>,
166}
167
168pub const SNAPSHOT_STALENESS: Duration = Duration::from_secs(10);
171
172pub const PINNED_GATE_WAIT: Duration = Duration::from_secs(3);
181
182#[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
194fn 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
206pub const POKE_INTERVAL: Duration = Duration::from_secs(1);
212
213pub const INDEX_STALENESS: Duration = Duration::from_secs(5);
217
218#[derive(Clone)]
220pub struct HeldFileIndex {
221 pub index: Arc<crate::file_index::FileIndex>,
222 pub read_at: Instant,
223}
224
225pub 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
235fn 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
244fn 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#[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 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#[derive(Default)]
285pub(crate) struct HeldSeats {
286 held: Mutex<HashMap<SessionSlot, Held>>,
287 gates: Mutex<HashMap<SessionSlot, std::sync::Weak<tokio::sync::Mutex<()>>>>,
293}
294
295struct Held {
296 count: usize,
299 stop: Option<tokio::sync::oneshot::Sender<()>>,
301 scanning: Arc<tokio::sync::Mutex<()>>,
305 first_read: Arc<tokio::sync::Notify>,
309 nudged: Arc<tokio::sync::Notify>,
314 pr_stale: Arc<std::sync::atomic::AtomicBool>,
318 #[cfg(any(test, feature = "testing"))]
321 test_tree_moved: Arc<std::sync::atomic::AtomicBool>,
322 #[cfg(any(test, feature = "testing"))]
325 pass_done: Arc<tokio::sync::Notify>,
326}
327
328struct 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 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 fn acquire(&self, slot: &SessionSlot) -> Taken {
357 let mut held = self.lock();
358 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 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 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 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 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 #[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 #[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 #[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 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 drop(entry.stop.take());
471 held.remove(slot);
472 }
473 }
474}
475
476impl Workspace {
477 pub fn hold_seat(self: &Arc<Self>, slot: &SessionSlot) {
507 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 pub fn note_record_read(&self, slot: &SessionSlot) {
523 self.held_work_seats.note_record_read(slot);
524 }
525
526 pub(crate) fn note_repository_activity(&self, slot: &SessionSlot) {
531 self.held_work_seats.nudge(slot, false);
532 }
533
534 pub(crate) fn note_code_change_published(&self, slot: &SessionSlot) {
538 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 fn take_pr_stale(&self, slot: &SessionSlot) -> bool {
553 self.held_work_seats.take_pr_stale(slot)
554 }
555
556 #[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 let _permit = gate.acquire().await;
565 }
566 }
567
568 pub fn release_seat(&self, slot: &SessionSlot) {
570 self.held_work_seats.release(slot);
571 }
572
573 pub fn work_snapshot(&self, slot: &SessionSlot) -> Option<WorkSnapshot> {
575 self.domain_session_for(slot)?.lock().work_snapshot.clone()
576 }
577
578 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 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 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 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 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 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 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 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 pub fn file_index(&self, slot: &SessionSlot) -> Option<HeldFileIndex> {
736 self.domain_session_for(slot)?.lock().file_index.clone()
737 }
738
739 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let Ok(held) = tokio::time::timeout(wait, gate.lock()).await else {
1023 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 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
1063fn 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
1081fn 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
1088fn 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
1102fn 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 let (changes, _watch) = start_change_watch(PathBuf::from(cwd), true);
1138 tokio::select! {
1139 _ = &mut stopped => return,
1140 () = first_read.notified() => {}
1141 }
1142 if stopped_while_waiting(&mut stopped) {
1145 return;
1146 }
1147 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 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 let woken = tokio::select! {
1162 _ = &mut stopped => return,
1163 _ = poke.tick() => false,
1164 () = nudged.notified() => true,
1165 };
1166 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 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 let pr_force = workspace.take_pr_stale(&slot);
1210 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 workspace.walk_processes_if_stale(&slot, workspace.claude_pid(&slot)).await;
1244 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 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 const KICK_SETTLE: Duration = Duration::from_secs(60);
1291
1292 fn seat() -> SessionSlot {
1293 SessionSlot::lead("TestOrg", "forge")
1294 }
1295
1296 fn hold_for_a_page(workspace: &Arc<Workspace>, seat: &SessionSlot) {
1300 workspace.hold_seat(seat);
1301 workspace.note_record_read(seat);
1302 }
1303
1304 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 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 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 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 struct PushedBranch {
1389 repo: tempfile::TempDir,
1390 tip: String,
1391 path: String,
1393 marker: std::path::PathBuf,
1394 _git_dir: Option<tempfile::TempDir>,
1396 }
1397
1398 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 fn new() -> (Self, tempfile::TempDir) {
1405 Self::new_over(false)
1406 }
1407
1408 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 fn env(&self) -> [(&str, &str); 2] {
1491 [("PATH", self.path.as_str()), ("GH_TOKEN", "t")]
1492 }
1493
1494 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 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 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 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 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 #[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 #[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 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 #[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 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 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 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 #[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 git(branch.repo.path(), &["commit", "--allow-empty", "-qm", "second"]);
1747
1748 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 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 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 #[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 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 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 #[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 #[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 #[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 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 #[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 #[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 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 #[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 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 #[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 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 #[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 #[tokio::test]
2127 async fn a_pinned_scan_gives_up_on_a_gate_held_past_its_bound() {
2128 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 #[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 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 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 #[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 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 #[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 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 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 #[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 #[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 #[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 #[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 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 #[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 #[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 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 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 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 assert!(
2593 started.elapsed() < Duration::from_millis(950),
2594 "the first pass ran within one poke: {:?}",
2595 started.elapsed(),
2596 );
2597 }
2598
2599 #[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 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 #[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 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 #[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 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 #[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 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 #[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 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 #[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 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 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 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 #[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 #[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 workspace.seed_test_claude_pid(&seat, std::process::id());
2902 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 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 #[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 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 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 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 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 #[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 #[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 #[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 let gate = Arc::new(tokio::sync::Semaphore::new(0));
3116 workspace.seed_test_scan_gate(Arc::clone(&gate));
3117 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 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 #[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 announced_work(&mut updates).await;
3173 let walked = announced_index(&mut updates).await;
3174 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 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 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 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 fn announce_failure(update: &SessionUpdate, held: &Arc<crate::file_index::FileIndex>) -> ! {
3225 panic!("{}", announce_message(update, held));
3226 }
3227
3228 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 #[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 #[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 #[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 #[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 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 #[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 workspace.release_session_with_cascade(&seat);
3369 workspace.hold_seat(&seat);
3370
3371 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 #[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 announced_work(&mut updates).await;
3404
3405 workspace.release_session_with_cascade(&seat);
3406
3407 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 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 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 #[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 #[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 #[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}