From b0cc6a461d5a30deec03c7cf2f39e5069bc5eee4 Mon Sep 17 00:00:00 2001 From: Frank O'Hara Date: Sun, 9 Aug 2026 09:42:19 -0600 Subject: [PATCH] feat(watcher): bound the event channel and full-reload on overflow The watcher's outbound channel was unbounded. A burst large enough to matter (a `git checkout` across a branch that touches thousands of task files) queued thousands of paths for a consumer that drains on a ~100ms tick and reindexes each one individually. Memory and latency both grew with the size of the burst, and the per-path work was pointless anyway once the answer became "most of the project changed". The channel is now a `sync_channel` of 256, comfortably above what an editing session produces and far below what a branch switch does. Past it, the debouncer stops sending and raises an overflow flag. `WatcherEvents::drain` hands the consumer the paths and the flag together, so a partial path list cannot be mistaken for a complete one. Reading the flag clears it, so one overflow is reported to one drain. Paths are taken before the flag: a path dropped in between is still covered by the flag, whereas clearing first could discard an overflow whose paths were never delivered. The TUI answers an overflow with `Store::handle_watcher_overflow`, which emits `StateDelta::FullReload` and clears the self-write hash table. That table exists to recognize the watcher echoing one of our own writes; an echo lost in the overflow will never arrive, and a stale entry would let a later genuine edit that reproduces those bytes be mistaken for the echo and dropped. `handle_full_reload` reindexes the whole project (still incremental, so unchanged files are skipped), refreshes the file tree, and restores cursor and expansion state the same way the single-file path does. Sends never block. The debouncer thread is also what observes the shutdown flag, so parking it on a full channel would make `drop` wait for a consumer that may itself be waiting on us. `watcher::start` now returns the receiver rather than taking a `Sender`, since the flag has to travel with it. Tests: a burst within capacity does not overflow; one past capacity fills the channel, delivers what fits and reports the drop; an overflow is reported to exactly one drain; 200 due paths through a channel of 1 returns rather than deadlocking. Plus store tests that overflow asks for a full reload and forgets pending self-write hashes. Closes the last open item in tasks/tasks.live-updates.md. --- crates/lash-core/src/store.rs | 55 +++++++++ crates/lash-core/src/watcher.rs | 210 ++++++++++++++++++++++++++++---- crates/lash-tui/src/app.rs | 99 +++++++++++++-- lash.index.md | 12 +- tasks/tasks.live-updates.md | 16 +-- 5 files changed, 346 insertions(+), 46 deletions(-) diff --git a/crates/lash-core/src/store.rs b/crates/lash-core/src/store.rs index cfe03c1..5d6eda8 100644 --- a/crates/lash-core/src/store.rs +++ b/crates/lash-core/src/store.rs @@ -74,6 +74,10 @@ pub enum StateDelta { /// Absolute path of the file that changed externally. absolute_path: PathBuf, }, + /// The watcher dropped events because its channel filled, so which files + /// changed is no longer known. Consumers should reindex the whole project + /// and reload the current view. + FullReload, /// A new task was created (either in an existing file or by creating a /// new file). Consumers should reindex and reload the affected file. TaskCreated { @@ -181,6 +185,19 @@ impl Store { } } + /// Handle the watcher having dropped events because its channel filled. + /// + /// Which files changed is unknowable at that point, so the only honest + /// answer is to reload everything. The write-hash table is cleared as part + /// of that: its entries exist to recognize the watcher echoing one of our + /// own writes, and an echo we never received is one we can no longer match. + /// Keeping a stale entry would let a genuine later edit that happens to + /// reproduce those bytes be mistaken for our own write and dropped. + pub fn handle_watcher_overflow(&mut self) -> Vec { + self.last_written_hash.clear(); + vec![StateDelta::FullReload] + } + /// Process an external-change notification for `path`. Returns an empty /// vec if the on-disk content matches our most recent write to this path /// (i.e. the watcher is echoing our own write) — and otherwise returns a @@ -484,6 +501,44 @@ mod tests { )); } + #[test] + fn handle_watcher_overflow_asks_for_a_full_reload() { + let mut store = Store::new(); + + assert_eq!( + store.handle_watcher_overflow(), + vec![StateDelta::FullReload] + ); + } + + #[test] + fn handle_watcher_overflow_forgets_pending_self_write_hashes() { + // The hash exists to recognize the watcher echoing our own write. If + // the echo was among the dropped events it will never arrive, and a + // stale entry would let a later genuine edit that reproduces those + // bytes be mistaken for the echo and dropped. + let (_dir, path) = fixture(); + let mut store = Store::new(); + store + .apply(Mutation::SetTaskStatus { + absolute_path: path.clone(), + task_title: "First task".into(), + old_status: TaskStatus::Open, + new_status: TaskStatus::Done, + }) + .unwrap(); + assert!(store.has_recorded_hash(&path)); + + store.handle_watcher_overflow(); + + assert!(!store.has_recorded_hash(&path)); + assert_eq!( + store.handle_external_change(&path).unwrap().len(), + 1, + "the same content must now read as an external edit, not an echo" + ); + } + #[test] fn handle_external_change_emits_reload_when_no_prior_self_write() { let (_dir, path) = fixture(); diff --git a/crates/lash-core/src/watcher.rs b/crates/lash-core/src/watcher.rs index b596163..c1ccb00 100644 --- a/crates/lash-core/src/watcher.rs +++ b/crates/lash-core/src/watcher.rs @@ -16,12 +16,22 @@ //! - any path inside `.git/`, `target/`, `.lash/`, or `node_modules/` is dropped //! - identical path events arriving within `DEBOUNCE` are coalesced into one //! -//! See `docs/live-tui-updates.md` Phase C for the broader context. +//! The outbound channel is bounded. A burst large enough to fill it (a +//! `git checkout` across a branch that touches thousands of task files, say) +//! would otherwise queue thousands of individual reindexes for a consumer that +//! drains on a ~100ms tick. Instead the watcher drops the paths it cannot +//! enqueue and raises an overflow flag, and the consumer reloads everything +//! once. Losing which files changed is fine when the answer is "too many to +//! be worth tracking"; what must not happen is losing the fact that something +//! changed. +//! +//! See `docs/live-tui-updates.md` Phase C for the broader context, and Phase D +//! for the backpressure above. use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::mpsc::{self, Sender}; +use std::sync::mpsc::{self, SyncSender, TrySendError}; use std::sync::Arc; use std::thread; use std::time::{Duration, Instant}; @@ -34,10 +44,55 @@ use lash_types::{LashError, Result}; /// Default debounce window for coalescing watcher events per-path. pub const DEBOUNCE: Duration = Duration::from_millis(150); +/// Capacity of the watcher's outbound channel. +/// +/// Comfortably above what a normal editing session produces, and far below +/// what a branch switch does. Past it, per-path delivery stops being useful +/// and [`WatcherEvents::drain`] reports an overflow instead. +pub const CHANNEL_CAPACITY: usize = 256; + /// Directory components we never recurse into. Cheap substring check — these /// are well-known directories so false positives are not a concern. const IGNORED_DIRS: &[&str] = &[".git", "target", ".lash", "node_modules"]; +/// What a drain of the watcher channel found. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WatcherDrain { + /// Paths that changed, debounced and filtered. + pub paths: Vec, + + /// True if the watcher had to drop paths because the channel was full. + /// + /// `paths` is then an arbitrary subset of what changed, and the consumer + /// should reload everything rather than trust it. + pub overflowed: bool, +} + +/// Receive end of the watcher's bounded channel. +/// +/// Wraps the channel together with the overflow flag so a consumer cannot read +/// one without the other: the paths are only complete if `overflowed` is false. +pub struct WatcherEvents { + rx: mpsc::Receiver, + overflowed: Arc, +} + +impl WatcherEvents { + /// Take everything currently queued, without blocking. + /// + /// Reading the overflow flag clears it, so an overflow is reported to + /// exactly one drain. + #[must_use] + pub fn drain(&self) -> WatcherDrain { + // Paths first: a path dropped between here and the flag read is + // covered by the flag, whereas clearing the flag first could discard + // an overflow whose paths were never delivered. + let paths: Vec = self.rx.try_iter().collect(); + let overflowed = self.overflowed.swap(false, Ordering::AcqRel); + WatcherDrain { paths, overflowed } + } +} + /// Handle to a running watcher. Drop this to stop the watcher thread. /// /// Shutdown is synchronous: once `drop` returns, the debouncer thread has @@ -85,8 +140,8 @@ impl Drop for FileWatcherHandle { /// Returns an error if the underlying `notify` watcher fails to start or /// fails to register `root`. #[allow(clippy::needless_pass_by_value)] -pub fn start(root: PathBuf, tx: Sender) -> Result { - start_with_debounce(root, tx, DEBOUNCE) +pub fn start(root: PathBuf) -> Result<(FileWatcherHandle, WatcherEvents)> { + start_with_debounce(root, DEBOUNCE) } /// Variant of `start` that exposes the debounce window. Used by tests to @@ -98,9 +153,12 @@ pub fn start(root: PathBuf, tx: Sender) -> Result { #[allow(clippy::needless_pass_by_value)] pub fn start_with_debounce( root: PathBuf, - out: Sender, debounce: Duration, -) -> Result { +) -> Result<(FileWatcherHandle, WatcherEvents)> { + let (out, rx) = mpsc::sync_channel::(CHANNEL_CAPACITY); + let overflowed = Arc::new(AtomicBool::new(false)); + let thread_overflowed = Arc::clone(&overflowed); + let (raw_tx, raw_rx) = mpsc::channel::(); let mut watcher = notify::recommended_watcher(move |res: notify::Result| { @@ -129,7 +187,9 @@ pub fn start_with_debounce( let debouncer_thread = thread::Builder::new() .name("lash-file-watcher-debouncer".into()) - .spawn(move || debouncer_loop(raw_rx, out, debounce, &thread_shutdown)) + .spawn(move || { + debouncer_loop(raw_rx, out, debounce, &thread_shutdown, &thread_overflowed); + }) .map_err(|e| LashError::IO { code: codes::E_IO_READ_ERROR, message: format!("failed to spawn watcher debouncer thread: {e}"), @@ -137,11 +197,14 @@ pub fn start_with_debounce( io_error: Some(e.to_string()), })?; - Ok(FileWatcherHandle { - watcher: Some(watcher), - debouncer_thread: Some(debouncer_thread), - shutdown, - }) + Ok(( + FileWatcherHandle { + watcher: Some(watcher), + debouncer_thread: Some(debouncer_thread), + shutdown, + }, + WatcherEvents { rx, overflowed }, + )) } /// Returns true if the path is a Markdown file we care about (i.e. has `.md` @@ -165,9 +228,10 @@ fn is_relevant(path: &Path) -> bool { #[allow(clippy::needless_pass_by_value)] fn debouncer_loop( raw_rx: mpsc::Receiver, - out: Sender, + out: SyncSender, debounce: Duration, shutdown: &AtomicBool, + overflowed: &AtomicBool, ) { let mut pending: HashMap = HashMap::new(); @@ -212,8 +276,13 @@ fn debouncer_loop( } }); for path in to_emit { - if out.send(path).is_err() { - return; + // Never block here. The debouncer thread is also what notices + // shutdown, so parking it on a full channel would make `drop` wait + // for a consumer that may itself be waiting on us. + match out.try_send(path) { + Ok(()) => {} + Err(TrySendError::Full(_)) => overflowed.store(true, Ordering::Release), + Err(TrySendError::Disconnected(_)) => return, } } } @@ -239,7 +308,7 @@ mod tests { use super::*; use std::time::Duration; - fn drain_events(rx: &mpsc::Receiver, deadline: Duration) -> Vec { + fn drain_events(events: &WatcherEvents, deadline: Duration) -> Vec { let mut out = Vec::new(); let end = Instant::now() + deadline; loop { @@ -247,7 +316,7 @@ mod tests { if remaining.is_zero() { break; } - match rx.recv_timeout(remaining) { + match events.rx.recv_timeout(remaining) { Ok(p) => out.push(p), Err(_) => break, } @@ -270,10 +339,16 @@ mod tests { // Control: no shutdown signalled, so the due path is emitted. let (raw_tx, raw_rx) = mpsc::channel(); - let (out_tx, out_rx) = mpsc::channel(); + let (out_tx, out_rx) = mpsc::sync_channel(CHANNEL_CAPACITY); raw_tx.send(md_event(&path)).unwrap(); drop(raw_tx); - debouncer_loop(raw_rx, out_tx, Duration::ZERO, &AtomicBool::new(false)); + debouncer_loop( + raw_rx, + out_tx, + Duration::ZERO, + &AtomicBool::new(false), + &AtomicBool::new(false), + ); assert_eq!( out_rx.iter().collect::>(), vec![path.clone()], @@ -282,16 +357,100 @@ mod tests { // Same input, shutdown already signalled: the path is abandoned. let (raw_tx, raw_rx) = mpsc::channel(); - let (out_tx, out_rx) = mpsc::channel(); + let (out_tx, out_rx) = mpsc::sync_channel(CHANNEL_CAPACITY); raw_tx.send(md_event(&path)).unwrap(); drop(raw_tx); - debouncer_loop(raw_rx, out_tx, Duration::ZERO, &AtomicBool::new(true)); + debouncer_loop( + raw_rx, + out_tx, + Duration::ZERO, + &AtomicBool::new(true), + &AtomicBool::new(false), + ); assert!( out_rx.iter().next().is_none(), "no path may be emitted once shutdown is signalled" ); } + /// Run the debouncer over `count` distinct paths with a zero debounce (so + /// everything is due immediately) against a channel of size `capacity`. + fn run_debouncer_over(count: usize, capacity: usize) -> (Vec, bool) { + let (raw_tx, raw_rx) = mpsc::channel(); + let (out_tx, out_rx) = mpsc::sync_channel(capacity); + for i in 0..count { + raw_tx + .send(md_event(&PathBuf::from(format!("/tmp/proj/task-{i}.md")))) + .unwrap(); + } + drop(raw_tx); + + let overflowed = Arc::new(AtomicBool::new(false)); + debouncer_loop( + raw_rx, + out_tx, + Duration::ZERO, + &AtomicBool::new(false), + &overflowed, + ); + + let events = WatcherEvents { + rx: out_rx, + overflowed, + }; + let drained = events.drain(); + (drained.paths, drained.overflowed) + } + + #[test] + fn a_burst_within_capacity_does_not_overflow() { + let (paths, overflowed) = run_debouncer_over(4, 8); + + assert_eq!(paths.len(), 4); + assert!(!overflowed, "capacity was never reached"); + } + + #[test] + fn a_burst_past_capacity_reports_overflow_and_keeps_running() { + // A branch switch touching far more files than the channel holds. The + // paths that do fit are still delivered, but the flag says they are + // only a subset. + let (paths, overflowed) = run_debouncer_over(40, 8); + + assert!(overflowed, "dropped paths must be reported"); + assert_eq!( + paths.len(), + 8, + "the channel should be full, not empty or unbounded" + ); + } + + #[test] + fn overflow_is_reported_to_exactly_one_drain() { + let events = WatcherEvents { + rx: mpsc::sync_channel::(1).1, + overflowed: Arc::new(AtomicBool::new(true)), + }; + + assert!(events.drain().overflowed); + assert!( + !events.drain().overflowed, + "a second drain must not re-report the same overflow" + ); + } + + #[test] + fn a_full_channel_does_not_block_shutdown() { + // The debouncer thread is also what observes the shutdown flag, so + // blocking it on a full channel would make `drop` wait for a consumer + // that may never drain. Capacity 1 with 200 due paths would deadlock + // if the send blocked; that it returns at all is the assertion. + let (paths, overflowed) = run_debouncer_over(200, 1); + + assert!(overflowed); + assert_eq!(paths.len(), 1); + } + #[test] fn is_relevant_accepts_md_outside_ignored() { assert!(is_relevant(Path::new("/tmp/foo/tasks.md"))); @@ -318,8 +477,7 @@ mod tests { let path = root.join("tasks.md"); std::fs::write(&path, "initial\n").unwrap(); - let (tx, rx) = mpsc::channel(); - let _handle = start_with_debounce(root, tx, Duration::from_millis(50)).unwrap(); + let (_handle, rx) = start_with_debounce(root, Duration::from_millis(50)).unwrap(); // notify needs a moment to wire up after watch() returns. std::thread::sleep(Duration::from_millis(80)); @@ -355,8 +513,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let root = dir.path().to_path_buf(); - let (tx, rx) = mpsc::channel(); - let _handle = start_with_debounce(root.clone(), tx, Duration::from_millis(50)).unwrap(); + let (_handle, rx) = start_with_debounce(root.clone(), Duration::from_millis(50)).unwrap(); std::thread::sleep(Duration::from_millis(80)); std::fs::write(root.join("ignored.txt"), "hi\n").unwrap(); @@ -383,8 +540,7 @@ mod tests { let path = root.join("tasks.md"); std::fs::write(&path, "initial\n").unwrap(); - let (tx, rx) = mpsc::channel(); - let handle = start_with_debounce(root, tx, Duration::from_millis(50)).unwrap(); + let (handle, rx) = start_with_debounce(root, Duration::from_millis(50)).unwrap(); std::thread::sleep(Duration::from_millis(80)); // Clear anything the watcher queued before the drop. macOS FSEvents diff --git a/crates/lash-tui/src/app.rs b/crates/lash-tui/src/app.rs index 028c91f..8633aa5 100644 --- a/crates/lash-tui/src/app.rs +++ b/crates/lash-tui/src/app.rs @@ -4,7 +4,6 @@ use ratatui::{backend::Backend, backend::CrosstermBackend, Terminal}; use rusqlite::Connection; use std::io; use std::path::{Path, PathBuf}; -use std::sync::mpsc; use std::time::Duration; use lash_core::store::StateDelta; @@ -48,7 +47,7 @@ pub struct TuiAppCore { store: lash_core::store::Store, /// Receiver for paths reported by the file watcher (production only) - external_rx: Option>, + external_rx: Option, /// Live file watcher handle; kept alive for the app's lifetime so the /// watcher thread doesn't shut down. `None` in tests where no real fs @@ -300,15 +299,14 @@ fn start_watcher( project_root: &Path, ) -> Result< ( - mpsc::Receiver, + lash_core::watcher::WatcherEvents, lash_core::watcher::FileWatcherHandle, ), String, > { - let (tx, rx) = mpsc::channel(); - let handle = - lash_core::watcher::start(project_root.to_path_buf(), tx).map_err(|e| format!("{e}"))?; - Ok((rx, handle)) + let (handle, events) = + lash_core::watcher::start(project_root.to_path_buf()).map_err(|e| format!("{e}"))?; + Ok((events, handle)) } // Generic implementation for all backend and event source combinations @@ -1435,11 +1433,22 @@ impl TuiAppCore { /// Drain any pending file-watcher events, route them through the Store's /// hash dedupe, and apply the resulting state deltas. fn drain_external_changes(&mut self) -> TuiResult<()> { - let Some(rx) = self.external_rx.as_ref() else { + let Some(events) = self.external_rx.as_ref() else { return Ok(()); }; - let paths: Vec = rx.try_iter().collect(); - for path in paths { + let drained = events.drain(); + + // The watcher dropped paths, so `drained.paths` is an arbitrary subset + // of what changed. Reload everything instead of acting on a partial + // list, and skip the per-path work: a full reload subsumes it. + if drained.overflowed { + for delta in self.store.handle_watcher_overflow() { + self.apply_delta(delta)?; + } + return Ok(()); + } + + for path in drained.paths { self.process_external_change(&path)?; } Ok(()) @@ -1466,6 +1475,7 @@ impl TuiAppCore { fn apply_delta(&mut self, delta: StateDelta) -> TuiResult<()> { match delta { StateDelta::FileReloaded { absolute_path } => self.handle_file_reloaded(&absolute_path), + StateDelta::FullReload => self.handle_full_reload(), // TaskStatusChanged is only emitted by `Store::apply` (the call // site already handles it directly) — external changes manifest // as FileReloaded which routes through `apply_external_status_diff`. @@ -1539,6 +1549,45 @@ impl TuiAppCore { Ok(()) } + /// Reindex the whole project and reload the view, for when the watcher + /// dropped events and the set of changed files is no longer known. + /// + /// Deliberately no status diffing: the activity bar reports transitions, + /// and after an overflow there is no per-file before-state to compare + /// against. A branch switch is not a set of task transitions the user + /// wants narrated anyway. + fn handle_full_reload(&mut self) -> TuiResult<()> { + if let Err(e) = self.reindex_all() { + self.state + .set_warning_message(format!("full reindex failed: {e}")); + return Ok(()); + } + + // Every open modal is now stale: its baseline came from state that may + // no longer exist. + self.mark_all_modals_stale(); + + let viewing_id = self.currently_viewed_file_id(); + self.refresh_files_and_tree()?; + + if let Some(file_id) = viewing_id { + let preserved_full_id = self.state.selected_task_full_id(); + let expanded_ids = self.state.collect_expansion_state(); + + self.state.tasks = TaskRepository::new(&self.conn) + .get_by_file(file_id) + .map_err(|e| TuiError::App(format!("Failed to reload tasks: {e}")))?; + self.state.build_task_tree(); + self.state.restore_expansion_state(&expanded_ids); + if let Some(full_id) = preserved_full_id { + self.state.restore_task_selection_by_full_id(&full_id); + } + } + + self.refresh_project_stats()?; + Ok(()) + } + /// Reload `state.files` from the DB and rebuild the file tree, preserving /// the user's current expansion state and file selection. Called after /// every external change so the left-pane file tree reflects sibling @@ -1622,6 +1671,19 @@ impl TuiAppCore { } } + /// Mark every open modal stale, for a reload where the set of changed + /// files is unknown and so no modal's baseline can be trusted. + fn mark_all_modals_stale(&mut self) { + if let Some(modal) = self.state.task_creation_modal_state.as_mut() { + if !modal.stale { + modal.stale = true; + self.state.set_warning_message( + "Project changed on disk — press Esc to discard and retry", + ); + } + } + } + /// Compare the post-reindex tasks of `absolute_path` against `pre` and /// route every status change through `ActivityState::record_transition`. fn apply_external_status_diff( @@ -1665,6 +1727,23 @@ impl TuiAppCore { self.state.selected_file().map(|f| f.id) } + /// Reindex every task file under the project root. + /// + /// Still incremental: the indexer skips files whose content hash is + /// unchanged, so the cost is proportional to what actually changed rather + /// than to project size. + fn reindex_all(&self) -> Result<(), String> { + let parser_config = lash_types::LashConfig::default(); + let config = lash_db::indexer::IndexerConfig::new(self.project_root.clone()) + .with_incremental(true) + .with_progress(false); + let mut indexer = lash_db::indexer::Indexer::new(&self.conn, config, &parser_config); + indexer + .index_project() + .map(|_| ()) + .map_err(|e| format!("{e}")) + } + fn reindex_paths(&self, paths: &[PathBuf]) -> Result<(), String> { let parser_config = lash_types::LashConfig::default(); let config = lash_db::indexer::IndexerConfig::new(self.project_root.clone()) diff --git a/lash.index.md b/lash.index.md index b2c0908..f688017 100644 --- a/lash.index.md +++ b/lash.index.md @@ -57,7 +57,17 @@ For background docs, see: warning-colored. User Esc-discards and retries against fresh state. The other (transient) confirm modals are not yet covered — typical open-window is sub-second so the risk window is much smaller -- [ ] Bounded watcher channel with `FullReload` overflow path #core +- [x] Bounded watcher channel with `FullReload` overflow path #core + - The watcher's outbound channel is a `sync_channel` of 256. Past that the + debouncer stops sending and raises an overflow flag instead of queueing; + `WatcherEvents::drain` hands the consumer paths and the flag together, so + a partial path list cannot be read as a complete one. The TUI answers an + overflow with `Store::handle_watcher_overflow`, which clears the + self-write hash table and emits `StateDelta::FullReload`, and reindexes + the whole project instead of acting on the subset + - Sends never block: the debouncer thread is also what observes the shutdown + flag, so parking it on a full channel would make `drop` wait on a consumer + that may itself be waiting ### CLI hygiene diff --git a/tasks/tasks.live-updates.md b/tasks/tasks.live-updates.md index fbbb524..13e1240 100644 --- a/tasks/tasks.live-updates.md +++ b/tasks/tasks.live-updates.md @@ -93,8 +93,8 @@ rare, and `lash format` from the CLI never races with a running TUI). - [x] `update_markdown_task_status` → `store.apply(SetTaskStatus { ... })` - [x] All five callers (`handle_toggle_status` + 3 cascade handlers + linked-file complete) go through the new helper unchanged - [x] Existing TUI tests (160 lib tests) still pass -- [ ] `handle_submit_task_creation` write path → `store.apply(CreateTask { ... })` — deferred; needs `Mutation::CreateTask` -- [ ] `lash_core::formatter::format_file_in_place` uses `write_atomic` — deferred; nice-to-have for safety, no behavioral impact +- [x] `handle_submit_task_creation` write path → `store.apply(CreateTask { ... })` +- [x] `lash_core::formatter::format_file_in_place` uses `write_atomic` ### Acceptance @@ -199,10 +199,10 @@ If an in-flight task-creation or edit modal targets a file that is ### Subtasks -- [ ] Add `stale: bool` to relevant modal states -- [ ] On `FileReloaded { path }`, set `stale = true` if any open modal targets `path` -- [ ] On submit, refuse if stale; show banner -- [ ] `R` key reloads the modal's baseline from the Store and clears the stale flag +- [x] Add `stale: bool` to relevant modal states +- [x] On `FileReloaded { path }`, set `stale = true` if any open modal targets `path` +- [x] On submit, refuse if stale; show banner +- [-] `R` key reloads the modal's baseline from the Store and clears the stale flag — Esc-and-retry covers it; a reload-in-place key is not worth the extra state ### Acceptance @@ -219,5 +219,5 @@ If an in-flight task-creation or edit modal targets a file that is ### Description -- Bounded watcher channel; on overflow, emit a `FullReload` delta instead. -- Optional: directory-scoped watch for very large projects (deferred unless a real perf complaint surfaces). +- [x] Bounded watcher channel; on overflow, emit a `FullReload` delta instead. +- [-] Optional: directory-scoped watch for very large projects — deferred; no perf complaint has surfaced, and the bounded channel now caps the damage from a large burst.