Skip to main content

forge_workspace/
review.rs

1//! The review-conversation cluster on [`Workspace`]: durable
2//! review-thread CRUD over the redb store, review submit / list / get,
3//! worker reply + resolve, the per-turn activity drain that notifies the
4//! reviewer, and the dead-branch sweep run off the startup path.
5//!
6//! Everything here stays on `Workspace` as a second `impl` block, so
7//! every caller (the `Command` bus arms in [`crate::workspace`],
8//! `session_task`'s turn-end drain, the `mcp::review` facade,
9//! a view's diff overlay) keeps its path. The `db`, `review_origin`
10//! and `review_activity` fields these methods own are `pub(crate)` for
11//! the same reason `pool` is: so this sibling module can reach them
12//! without a wrapper. Store IO lives in [`crate::store::review`]; the
13//! MCP tool surface in [`crate::mcp::review`].
14
15use std::collections::HashMap;
16use std::sync::Arc;
17
18use forge_primitives::review::{is_doc_scope, is_tree_scope};
19use forge_primitives::{ReviewStatus, ReviewThread};
20
21use crate::protocol::SessionUpdate;
22use crate::target::SessionSlot;
23use crate::workspace::Workspace;
24
25impl Workspace {
26    /// Persisted review threads for `(project, branch)`, empty when the
27    /// DB isn't open or the read fails. `project` is the forge.toml
28    /// project NAME (worktree-agnostic). Query-style, so this is a direct
29    /// method - review-thread persistence is local redb IO, not an
30    /// agent-driving action that needs the `Command` bus.
31    /// Load the persisted review threads for `(project, branch)`. `Ok`
32    /// with an empty vec when the store isn't open or the branch has no
33    /// row; `Err` with a display string when an existing row fails to
34    /// decode / read, so the overlay can surface the failure instead of
35    /// showing a silently-empty review pane.
36    pub fn load_review_threads(
37        &self,
38        project: &str,
39        branch: &str,
40    ) -> Result<Vec<ReviewThread>, String> {
41        let guard = self.db.lock();
42        let Some(db) = guard.as_ref() else {
43            return Ok(Vec::new());
44        };
45        crate::store::review::load(db, project, branch).map_err(|error| {
46            tracing::warn!(
47                target: "forge_workspace::review",
48                %error,
49                project = %project,
50                branch = %branch,
51                "loading review threads failed",
52            );
53            format!("{error:#}")
54        })
55    }
56
57    /// Insert or replace one review thread by id in `(project, branch)`.
58    /// Returns whether the write was confirmed - `false` when the store
59    /// isn't open or the write failed, so the caller can leave the
60    /// comment in the at-risk (not-yet-durable) bucket.
61    pub fn upsert_review_thread(&self, project: &str, branch: &str, thread: ReviewThread) -> bool {
62        let guard = self.db.lock();
63        let Some(db) = guard.as_ref() else {
64            return false;
65        };
66        match crate::store::review::upsert(db, project, branch, thread) {
67            Ok(()) => true,
68            Err(error) => {
69                tracing::warn!(
70                    target: "forge_workspace::review",
71                    %error,
72                    project = %project,
73                    branch = %branch,
74                    "upserting a review thread failed",
75                );
76                false
77            }
78        }
79    }
80
81    /// Remove one review thread by id from `(project, branch)`. Returns
82    /// whether a thread was removed - `false` when the store isn't open,
83    /// the write failed, or no thread carried the id.
84    pub fn delete_review_thread(&self, project: &str, branch: &str, id: &str) -> bool {
85        let guard = self.db.lock();
86        let Some(db) = guard.as_ref() else {
87            return false;
88        };
89        match crate::store::review::remove_thread(db, project, branch, id) {
90            Ok(removed) => removed,
91            Err(error) => {
92                tracing::warn!(
93                    target: "forge_workspace::review",
94                    %error,
95                    project = %project,
96                    branch = %branch,
97                    "deleting a review thread failed",
98                );
99                false
100            }
101        }
102    }
103
104    /// Set the status of one review thread by id, bumping its `updated_at`.
105    pub fn set_review_thread_status(
106        &self,
107        project: &str,
108        branch: &str,
109        id: &str,
110        status: ReviewStatus,
111    ) {
112        if let Some(db) = self.db.lock().as_ref()
113            && let Err(error) = crate::store::review::set_status(db, project, branch, id, status)
114        {
115            tracing::warn!(
116                target: "forge_workspace::review",
117                %error,
118                project = %project,
119                branch = %branch,
120                id = %id,
121                "setting a review-thread status failed",
122            );
123        }
124    }
125
126    /// Delete all of `(project, branch)`'s review state - threads and
127    /// reviews together, in one transaction. Called on worktree teardown
128    /// once the branch itself is gone, and by the boot sweep for a branch
129    /// deleted since, so orphaned rows don't linger and a later branch
130    /// reusing the name inherits nothing.
131    pub fn delete_branch_review_state(&self, project: &str, branch: &str) {
132        if let Some(db) = self.db.lock().as_ref()
133            && let Err(error) = crate::store::review::delete_branch_state(db, project, branch)
134        {
135            tracing::warn!(
136                target: "forge_workspace::review",
137                %error,
138                project = %project,
139                branch = %branch,
140                "deleting review state failed",
141            );
142        }
143    }
144
145    /// Branch refs a repo must hold before a majority-dead sweep of it is
146    /// trusted.
147    ///
148    /// Measured against real clones, counting branch refs rather than
149    /// names: a `--depth 1` or `--single-branch` clone holds two, its
150    /// local head and the one remote-tracking ref it fetched, whatever
151    /// those branches are called. A full clone of a four-branch repo
152    /// holds five. Three sits in that gap.
153    ///
154    /// It has to be refs and not names. The name set over-includes on
155    /// purpose, one phantom per slash, so `release/2.0/rc1` adds `2.0/rc1`
156    /// and `rc1` beside itself and pushes a two-ref clone to three names -
157    /// over the bound, guard silent. One slash is not enough to show it:
158    /// `release/2.0` gives two names against two refs, which is why a
159    /// test built on it passes whichever quantity the guard reads.
160    ///
161    /// Three is not a tuning knob, it is the only position where the
162    /// guard exists. A solo repo - one `main`, no remote - knows ONE
163    /// name, which is BELOW the shallow clone's two. So any value that
164    /// spares the solo repo also spares the shallow clone, and lowering
165    /// to two sweeps the shallow clone anyway. There is no threshold that
166    /// separates them; a solo repo with most of its review branches
167    /// merged is refused, and that is the cost of the guard rather than
168    /// something to calibrate away.
169    const MIN_POPULATED_REFS: usize = 3;
170
171    /// Drop the review threads and reviews of every branch that no longer
172    /// exists in the repo they were filed against, and report how many
173    /// branches were cleared.
174    ///
175    /// Every branch is judged by exact membership in one listing per
176    /// project - see [`forge_agent::env::worktree::repo_branch_names`] for
177    /// why a per-branch ref pattern cannot answer this. A project whose
178    /// root does not answer as a work-tree root is skipped whole, as is
179    /// one absent from `forge.toml`: there is nothing to check against.
180    pub fn sweep_dead_review_branches(&self) -> usize {
181        let mut cleared = 0;
182        for view in self.list_projects() {
183            let Some(repo) = forge_agent::env::worktree::repo_branch_names(&view.path) else {
184                continue;
185            };
186            let stored = {
187                let guard = self.db.lock();
188                // Workspace-wide, so a closed store ends the sweep rather
189                // than skipping one project - every project after this
190                // would fork two git processes to reach the same answer.
191                let Some(db) = guard.as_ref() else {
192                    return cleared;
193                };
194                match crate::store::review::stored_branches(db, &view.name) {
195                    Ok(stored) => stored,
196                    Err(error) => {
197                        tracing::warn!(
198                            target: "forge_workspace::review",
199                            %error,
200                            project = %view.name,
201                            "review-branch sweep skipped: listing stored branches failed",
202                        );
203                        continue;
204                    }
205                }
206            };
207            // Claude puts a worker's worktree on `worktree-<label>`, which
208            // does not exist until the worktree is created. Snapshotted
209            // here, so it covers workers registered up to this point and
210            // not the window from here to the delete. It is not a boot
211            // race either: `live_workers` is still empty this early, the
212            // only production insert being downstream of this call.
213            let spawning: std::collections::HashSet<String> = self
214                .list_live_workers(&view.key)
215                .into_iter()
216                .map(|worker| format!("worktree-{}", worker.label))
217                .collect();
218            let dead: Vec<&String> = stored
219                .iter()
220                // A doc scope is stored in the branch column and has no ref
221                // by construction, so absence proves nothing about it; a
222                // tree scope names a seat for the same reason.
223                .filter(|branch| {
224                    !is_doc_scope(branch)
225                        && !is_tree_scope(branch)
226                        && !repo.names.contains(*branch)
227                        && !spawning.contains(*branch)
228                })
229                .collect();
230            if dead.is_empty() {
231                continue;
232            }
233            // **The denominator is the BRANCH-shaped entries only.** A doc
234            // or tree scope can never be dead, so counting one in `stored`
235            // waters down the majority-dead ratio and makes the shallow-
236            // clone guard harder to earn than it reads - a repo whose pile
237            // is half scopes is refused only at a much higher dead share
238            // (the lead's gate, 2026-10-17).
239            let branches = stored
240                .iter()
241                .filter(|branch| !is_doc_scope(branch) && !is_tree_scope(branch))
242                .count();
243            if dead.len() * 2 > branches && repo.ref_count < Self::MIN_POPULATED_REFS {
244                tracing::warn!(
245                    target: "forge_workspace::review",
246                    project = %view.name,
247                    dead = dead.len(),
248                    stored = stored.len(),
249                    refs_seen = repo.ref_count,
250                    "review-branch sweep refused: most of this project's stored branches read as dead against a repo that knows almost no branches, which is what a shallow or single-branch clone looks like; nothing deleted",
251                );
252                continue;
253            }
254            for branch in dead {
255                let guard = self.db.lock();
256                let Some(db) = guard.as_ref() else {
257                    return cleared;
258                };
259                match crate::store::review::delete_branch_state(db, &view.name, branch) {
260                    Ok(()) => {
261                        tracing::info!(
262                            target: "forge_workspace::review",
263                            project = %view.name,
264                            branch = %branch,
265                            "dropped review state for a branch that no longer exists",
266                        );
267                        cleared += 1;
268                    }
269                    Err(error) => tracing::warn!(
270                        target: "forge_workspace::review",
271                        %error,
272                        project = %view.name,
273                        branch = %branch,
274                        "dropping review state failed",
275                    ),
276                }
277            }
278        }
279        cleared
280    }
281
282    /// Run [`Self::sweep_dead_review_branches`] off the startup path. Two
283    /// `git` calls per project, both blocking, so it goes on the blocking
284    /// pool rather than holding boot up.
285    pub fn start_review_branch_sweep(self: &Arc<Self>) {
286        let workspace = Arc::clone(self);
287        tokio::task::spawn_blocking(move || {
288            let cleared = workspace.sweep_dead_review_branches();
289            if cleared > 0 {
290                tracing::info!(
291                    target: "forge_workspace::review",
292                    cleared,
293                    "review-branch sweep cleared orphaned branches",
294                );
295            }
296        });
297    }
298
299    /// Load the submitted reviews for `(project, branch)`, oldest first.
300    /// `Ok` with an empty vec when the store isn't open or the branch has
301    /// no row; `Err` with a display string when an existing row fails to
302    /// decode, so the overlay can surface the failure.
303    pub fn load_reviews(
304        &self,
305        project: &str,
306        branch: &str,
307    ) -> Result<Vec<forge_primitives::ReviewSet>, String> {
308        let guard = self.db.lock();
309        let Some(db) = guard.as_ref() else {
310            return Ok(Vec::new());
311        };
312        crate::store::review::load_reviews(db, project, branch).map_err(|error| {
313            tracing::warn!(
314                target: "forge_workspace::review",
315                %error,
316                project = %project,
317                branch = %branch,
318                "loading reviews failed",
319            );
320            format!("{error:#}")
321        })
322    }
323
324    /// Worker answers on `(project, branch)` still owed a reviewer turn,
325    /// paired with when the oldest of them landed. `None` when nothing
326    /// waits or the row can't be read.
327    ///
328    /// The tally is the same `awaits_reviewer` count that
329    /// `drain_review_activity` puts on a notice and that the `/diff`
330    /// hydrate parks, over the same rows - so recomputing here
331    /// reproduces what those two would have written rather than
332    /// inventing a second number. `since` comes off the waiting
333    /// threads' own `updated_at`, which for a thread awaiting the
334    /// reviewer is when the agent replied, so a recompute dates the
335    /// wait from the answer rather than from the recompute.
336    pub fn review_replies_waiting(
337        &self,
338        project: &str,
339        branch: &str,
340    ) -> Option<(usize, std::time::SystemTime)> {
341        use time::format_description::well_known::Rfc3339;
342
343        let threads = self.load_review_threads(project, branch).ok()?;
344        let waiting: Vec<_> = threads.iter().filter(|t| t.awaits_reviewer()).collect();
345        if waiting.is_empty() {
346            return None;
347        }
348        let since = waiting
349            .iter()
350            .filter_map(|t| time::OffsetDateTime::parse(&t.updated_at, &Rfc3339).ok())
351            .min()
352            .map_or_else(std::time::SystemTime::now, std::time::SystemTime::from);
353        Some((waiting.len(), since))
354    }
355
356    /// The branches under `project` that hold submitted reviews. `Ok`
357    /// with an empty vec when the store isn't open; `Err` on a read
358    /// failure. `review__list` asks this only after its own branch came
359    /// back empty, so a review filed against another branch can't read
360    /// as "no reviews".
361    pub fn review_branches(&self, project: &str) -> Result<Vec<String>, String> {
362        let guard = self.db.lock();
363        let Some(db) = guard.as_ref() else {
364            return Ok(Vec::new());
365        };
366        crate::store::review::review_branches(db, project).map_err(|error| {
367            tracing::warn!(
368                target: "forge_workspace::review",
369                %error,
370                project = %project,
371                "listing review branches failed",
372            );
373            format!("{error:#}")
374        })
375    }
376
377    /// Seal a new review for `(project, branch)`, filing the listed
378    /// still-unfiled threads into it and appending it with the optional
379    /// summary. Records `origin` as the notice target for the branch (the
380    /// reviewer's session, so a later worker review-turn pings it). Returns
381    /// the minted [`forge_primitives::ReviewSet`] (`None` when the store
382    /// isn't open or the write failed) so the caller can surface its number.
383    pub fn submit_review(
384        &self,
385        project: &str,
386        branch: &str,
387        summary: Option<String>,
388        thread_ids: &[String],
389        origin: SessionSlot,
390    ) -> Option<forge_primitives::ReviewSet> {
391        // Both paths take `db` first and `review_origin` second, one direction
392        // only: this scopes the `db` guard to the store write and drops it
393        // BEFORE taking `review_origin`, and `drain_review_activity` builds
394        // its notices under brief `db` locks and takes `review_origin` last.
395        // The AB-BA the concurrency test guards against needs both sides to
396        // nest them the other way round - `review_origin` held across a db
397        // lock and vice versa - which is what a regression here would be.
398        let review = {
399            let guard = self.db.lock();
400            let db = guard.as_ref()?;
401            match crate::store::review::submit_review(db, project, branch, summary, thread_ids) {
402                Ok(review) => review,
403                Err(error) => {
404                    tracing::warn!(
405                        target: "forge_workspace::review",
406                        %error,
407                        project = %project,
408                        branch = %branch,
409                        "submitting a review failed",
410                    );
411                    return None;
412                }
413            }
414        };
415        self.review_origin.lock().insert((project.to_owned(), branch.to_owned()), origin);
416        Some(review)
417    }
418
419    /// The `review__list` view for `(project, branch)`: submitted reviews
420    /// with per-review comment tallies, newest first. `Ok` (possibly empty)
421    /// when the store is closed or the branch has no reviews; `Err` when a
422    /// stored row fails to decode, so the MCP surfaces the failure instead
423    /// of a silent empty list.
424    pub fn review_list(
425        &self,
426        project: &str,
427        branch: &str,
428    ) -> Result<Vec<crate::mcp::review::ReviewSummary>, String> {
429        let reviews = self.load_reviews(project, branch)?;
430        let threads = self.load_review_threads(project, branch)?;
431        Ok(crate::mcp::review::summarize(&reviews, &threads))
432    }
433
434    /// The `review__get` view for one review on `(project, branch)`: its
435    /// overview plus the comments filed under it, each carrying the
436    /// anchored code. `Ok(None)` when no such review; `Err` on a decode
437    /// failure.
438    pub fn review_get(
439        &self,
440        project: &str,
441        branch: &str,
442        review_id: &str,
443    ) -> Result<Option<crate::mcp::review::ReviewDetail>, String> {
444        let reviews = self.load_reviews(project, branch)?;
445        let threads = self.load_review_threads(project, branch)?;
446        Ok(crate::mcp::review::detail(&reviews, &threads, review_id))
447    }
448
449    /// Append a worker reply to `comment_id` on `(project, branch)` and
450    /// return the comment's anchor with the status after the append (Open
451    /// -> Addressed; Resolved / Outdated unchanged). Records the reply in
452    /// `caller`'s turn activity buffer so the reviewer is notified at turn
453    /// end. `Err` when the store is closed, the write fails, or no comment
454    /// with that id exists in this scope, so a stale or cross-branch id is
455    /// rejected rather than silently ignored.
456    pub fn review_reply(
457        &self,
458        caller: &SessionSlot,
459        project: &str,
460        branch: &str,
461        comment_id: &str,
462        author_label: &str,
463        text: &str,
464        at: &str,
465    ) -> Result<crate::mcp::review::CommentAnchorView, String> {
466        let anchor = {
467            let guard = self.db.lock();
468            let db = guard.as_ref().ok_or_else(|| "review store is unavailable".to_owned())?;
469            // The anchor is read BEFORE the write: a read failure then
470            // means nothing has landed and a failure is honest. The same
471            // read failing after the append would report a landed reply as
472            // failed, and a retry would duplicate it.
473            let (thread, reviews) = comment_anchor_parts(db, project, branch, comment_id)?;
474            let status = crate::store::review::append_reply(
475                db,
476                project,
477                branch,
478                comment_id,
479                author_label,
480                text,
481                at,
482            )
483            .map_err(|error| {
484                tracing::warn!(
485                    target: "forge_workspace::review",
486                    %error,
487                    project = %project,
488                    branch = %branch,
489                    comment_id = %comment_id,
490                    "appending a review reply failed",
491                );
492                format!("{error:#}")
493            })?;
494            crate::mcp::review::comment_anchor(&thread, &reviews, status)
495        };
496        self.note_review_activity(caller, project, branch, comment_id, true);
497        Ok(anchor)
498    }
499
500    /// Mark `comment_id` on `(project, branch)` Resolved and return the
501    /// comment's anchor with its new status. Records the resolve in
502    /// `caller`'s turn activity buffer. `Err` when the store is closed,
503    /// the write fails, or no comment with that id exists in this scope.
504    pub fn review_resolve(
505        &self,
506        caller: &SessionSlot,
507        project: &str,
508        branch: &str,
509        comment_id: &str,
510    ) -> Result<crate::mcp::review::CommentAnchorView, String> {
511        let anchor = {
512            let guard = self.db.lock();
513            let db = guard.as_ref().ok_or_else(|| "review store is unavailable".to_owned())?;
514            // Read before the write, as `review_reply` does: a failure here
515            // is reported before anything has landed.
516            let (thread, reviews) = comment_anchor_parts(db, project, branch, comment_id)?;
517            match crate::store::review::set_status(
518                db,
519                project,
520                branch,
521                comment_id,
522                ReviewStatus::Resolved,
523            ) {
524                Ok(true) => {}
525                Ok(false) => {
526                    return Err(format!("no review comment {comment_id} on ({project}, {branch})"));
527                }
528                Err(error) => {
529                    tracing::warn!(
530                        target: "forge_workspace::review",
531                        %error,
532                        project = %project,
533                        branch = %branch,
534                        comment_id = %comment_id,
535                        "resolving a review comment failed",
536                    );
537                    return Err(format!("{error:#}"));
538                }
539            }
540            crate::mcp::review::comment_anchor(&thread, &reviews, ReviewStatus::Resolved)
541        };
542        self.note_review_activity(caller, project, branch, comment_id, false);
543        Ok(anchor)
544    }
545
546    /// Append one review action to `caller`'s turn buffer, resolving the
547    /// review the action answers - the latest round the comment has a turn
548    /// in. An unfiled comment is skipped: there's no review to notify about.
549    fn note_review_activity(
550        &self,
551        caller: &SessionSlot,
552        project: &str,
553        branch: &str,
554        comment_id: &str,
555        replied: bool,
556    ) {
557        let review_id = {
558            let guard = self.db.lock();
559            let Some(db) = guard.as_ref() else { return };
560            match crate::store::review::find_thread_by_id(db, project, branch, comment_id) {
561                Ok(Some(thread)) => thread.latest_review().map(str::to_owned),
562                Ok(None) => None,
563                Err(error) => {
564                    tracing::warn!(
565                        target: "forge_workspace::review",
566                        %error,
567                        "resolving a review comment's owning review failed",
568                    );
569                    None
570                }
571            }
572        };
573        let Some(review_id) = review_id else { return };
574        self.review_activity.lock().entry(caller.clone()).or_default().push(
575            crate::mcp::review::ReviewActivity {
576                project: project.to_owned(),
577                branch: branch.to_owned(),
578                review_id,
579                replied,
580            },
581        );
582    }
583
584    /// Drain `caller`'s accumulated review activity into one
585    /// [`SessionUpdate::ReviewActivityNotice`] per touched review, routed to
586    /// the review's submit origin. A review with no recorded origin is
587    /// dropped (the reviewer still sees the state on `/diff` reopen) rather
588    /// than mis-routed to the caller. Called at the caller's turn end so a
589    /// multi-comment review turn produces a single batched tally instead of
590    /// one line per reply. Empty when the caller took no review actions this
591    /// turn.
592    pub(crate) fn drain_review_activity(&self, caller: &SessionSlot) -> Vec<SessionUpdate> {
593        // `review_activity` is this path's FIRST acquisition, while
594        // `note_review_activity` takes `db` first and `review_activity`
595        // second. Both release before their next acquisition, so no inversion
596        // exists today - and a nesting of that pair the other way round would
597        // be AB-BA exactly as the db / `review_origin` pair would, so keep
598        // this side free of any db-locking call while the guard is held.
599        let touches = { self.review_activity.lock().remove(caller).unwrap_or_default() };
600        if touches.is_empty() {
601            return Vec::new();
602        }
603        // Aggregate per review, preserving first-touched order.
604        let mut order: Vec<String> = Vec::new();
605        let mut by_review: HashMap<String, (String, String, usize, usize)> = HashMap::new();
606        for touch in touches {
607            let entry = by_review.entry(touch.review_id.clone()).or_insert_with(|| {
608                order.push(touch.review_id.clone());
609                (touch.project, touch.branch, 0, 0)
610            });
611            if touch.replied {
612                entry.2 += 1;
613            } else {
614                entry.3 += 1;
615            }
616        }
617
618        // Resolve each review's number + current open count via the store
619        // (each `review_list` call takes `db` briefly and releases it) and
620        // build its notice message BEFORE locking `review_origin`. Never
621        // hold `review_origin` across a db-locking call - `submit_review`
622        // locks db-then-origin, so the opposite order here would risk an
623        // AB-BA deadlock.
624        let mut messages: Vec<((String, String), usize, String)> = Vec::new();
625        for review_id in order {
626            let Some((project, branch, replied, resolved)) = by_review.remove(&review_id) else {
627                continue;
628            };
629            let loaded = self.load_reviews(&project, &branch).and_then(|reviews| {
630                self.load_review_threads(&project, &branch).map(|threads| (reviews, threads))
631            });
632            let (reviews, threads) = match loaded {
633                Ok(pair) => pair,
634                Err(error) => {
635                    // Surface the decode / IO failure rather than swallowing
636                    // it into a silently-skipped notice.
637                    tracing::warn!(
638                        target: "forge_workspace::review",
639                        %error,
640                        project = %project,
641                        branch = %branch,
642                        "review-activity notice skipped: loading reviews failed",
643                    );
644                    continue;
645                }
646            };
647            let rows = crate::mcp::review::summarize(&reviews, &threads);
648            let Some(summary) = rows.into_iter().find(|s| s.review_id == review_id) else {
649                continue;
650            };
651            // Branch-wide, not per-review: the reviewer's badge counts every
652            // answer still owed a look on the branch they are on.
653            let waiting = threads.iter().filter(|t| t.awaits_reviewer()).count();
654            let message = crate::mcp::review::notice_message(
655                summary.number,
656                replied,
657                resolved,
658                summary.open,
659                &branch,
660            );
661            messages.push(((project, branch), waiting, message));
662        }
663
664        // Map each review to its notice target under a short `review_origin`
665        // lock (no db lock held here). When no origin was recorded (e.g. the
666        // review was submitted before a restart - the map is in-memory only),
667        // DROP the notice rather than falling back to the caller: routing a
668        // "worker addressed review #N" line into the worker's own chat is
669        // worse than not firing. The reviewer still sees the state on /diff
670        // reopen (it persists in the store).
671        let origins = self.review_origin.lock();
672        messages
673            .into_iter()
674            .filter_map(|(scope, waiting, message)| {
675                if let Some(key) = origins.get(&scope) {
676                    Some(SessionUpdate::ReviewActivityNotice {
677                        key: key.clone(),
678                        branch: scope.1.clone(),
679                        waiting,
680                        message,
681                    })
682                } else {
683                    tracing::debug!(
684                        target: "forge_workspace::review",
685                        project = %scope.0,
686                        branch = %scope.1,
687                        caller = %caller.display(),
688                        "review-activity notice dropped: no submit origin recorded",
689                    );
690                    None
691                }
692            })
693            .collect()
694    }
695}
696
697/// The thread a reply or resolve names, and the branch's reviews: the
698/// parts an anchor echo is built from. Read before the write, so a
699/// failure cannot make a landed write look failed - and both calls
700/// double as the "no such comment" check, whose error the result would
701/// otherwise only surface as a missing thread.
702fn comment_anchor_parts(
703    db: &crate::store::Db,
704    project: &str,
705    branch: &str,
706    comment_id: &str,
707) -> Result<
708    (forge_primitives::review::ReviewThread, Vec<forge_primitives::review::ReviewSet>),
709    String,
710> {
711    let missing = || format!("no review comment {comment_id} on ({project}, {branch})");
712    let thread = crate::store::review::find_thread_by_id(db, project, branch, comment_id)
713        .map_err(|error| format!("{error:#}"))?
714        .ok_or_else(missing)?;
715    let reviews = crate::store::review::load_reviews(db, project, branch)
716        .map_err(|error| format!("{error:#}"))?;
717    Ok((thread, reviews))
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723
724    use tempfile::tempdir;
725
726    /// Seed a branch's thread set straight into the store, timestamps and
727    /// all - the per-thread write path stamps `updated_at` to now, which
728    /// several of these tests read back.
729    fn seed(ws: &Workspace, project: &str, branch: &str, threads: &[ReviewThread]) {
730        let db = ws.db.lock();
731        crate::store::review::save(db.as_ref().expect("test db present"), project, branch, threads)
732            .expect("seed threads");
733    }
734
735    #[test]
736    fn review_thread_crud_round_trips_through_the_workspace() {
737        use forge_primitives::review::{
738            ReviewAnchor, ReviewAuthor, ReviewComment, ReviewSide, ReviewStatus, ReviewThread,
739        };
740        let make = |id: &str, line: u32| ReviewThread {
741            id: id.to_owned(),
742            anchor: ReviewAnchor {
743                path: "src/x.rs".to_owned(),
744                side: ReviewSide::New,
745                line,
746                content_hash: u64::from(line),
747                context: vec!["ctx".to_owned()],
748                base_ref: "main".to_owned(),
749            },
750            comments: vec![ReviewComment {
751                author: ReviewAuthor::User,
752                text: format!("c{id}"),
753                at: "2026-07-19T10:00:00Z".to_owned(),
754                review_id: None,
755            }],
756            status: ReviewStatus::Open,
757            created_at: "2026-07-19T10:00:00Z".to_owned(),
758            updated_at: "2026-07-19T10:00:00Z".to_owned(),
759            commit: None,
760        };
761
762        let dir = tempdir().expect("tempdir");
763        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
764        ws.install_db_for_test(
765            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
766        );
767
768        assert!(ws.load_review_threads("forge", "feat").expect("load").is_empty(), "empty on miss");
769
770        ws.upsert_review_thread("forge", "feat", make("a", 10));
771        ws.upsert_review_thread("forge", "feat", make("b", 20));
772        assert_eq!(ws.load_review_threads("forge", "feat").expect("load").len(), 2);
773
774        ws.set_review_thread_status("forge", "feat", "a", ReviewStatus::Resolved);
775        let loaded = ws.load_review_threads("forge", "feat").expect("load");
776        assert_eq!(loaded.iter().find(|t| t.id == "a").expect("a").status, ReviewStatus::Resolved);
777        assert_eq!(loaded.iter().find(|t| t.id == "b").expect("b").status, ReviewStatus::Open);
778
779        // A different (project, branch) is scoped separately.
780        seed(&ws, "forge", "other", &[make("c", 30)]);
781        assert_eq!(
782            ws.load_review_threads("forge", "feat").expect("load").len(),
783            2,
784            "other branch is isolated"
785        );
786        assert_eq!(ws.load_review_threads("forge", "other").expect("load").len(), 1);
787
788        ws.delete_branch_review_state("forge", "feat");
789        assert!(
790            ws.load_review_threads("forge", "feat").expect("load").is_empty(),
791            "teardown clears the branch"
792        );
793        assert_eq!(
794            ws.load_review_threads("forge", "other").expect("load").len(),
795            1,
796            "delete is scoped"
797        );
798    }
799
800    /// The recompute must land on the number the two live writers would
801    /// have written - the `awaits_reviewer` tally over the branch's
802    /// rows - and date the wait from the answer that has been sitting
803    /// longest, not from the moment it was recomputed.
804    #[test]
805    fn review_replies_waiting_tallies_answers_and_dates_them_from_the_oldest() {
806        use forge_primitives::review::{
807            ReviewAnchor, ReviewAuthor, ReviewComment, ReviewSide, ReviewStatus, ReviewThread,
808        };
809        let thread = |id: &str, status: ReviewStatus, answered_at: Option<&str>| {
810            let mut comments = vec![ReviewComment {
811                author: ReviewAuthor::User,
812                text: "look at this".to_owned(),
813                at: "2026-07-19T10:00:00Z".to_owned(),
814                review_id: None,
815            }];
816            if let Some(at) = answered_at {
817                comments.push(ReviewComment {
818                    author: ReviewAuthor::Agent { label: "impl".to_owned() },
819                    text: "done".to_owned(),
820                    at: at.to_owned(),
821                    review_id: None,
822                });
823            }
824            ReviewThread {
825                id: id.to_owned(),
826                anchor: ReviewAnchor {
827                    path: "src/x.rs".to_owned(),
828                    side: ReviewSide::New,
829                    line: 1,
830                    content_hash: 1,
831                    context: vec!["ctx".to_owned()],
832                    base_ref: "main".to_owned(),
833                },
834                comments,
835                status,
836                created_at: "2026-07-19T10:00:00Z".to_owned(),
837                updated_at: answered_at.unwrap_or("2026-07-19T10:00:00Z").to_owned(),
838                commit: None,
839            }
840        };
841
842        let dir = tempdir().expect("tempdir");
843        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
844        ws.install_db_for_test(
845            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
846        );
847        assert!(ws.review_replies_waiting("forge", "feat").is_none(), "no rows, no signal");
848
849        seed(
850            &ws,
851            "forge",
852            "feat",
853            &[
854                thread("answered-late", ReviewStatus::Addressed, Some("2026-07-20T12:00:00Z")),
855                thread("answered-early", ReviewStatus::Addressed, Some("2026-07-19T11:00:00Z")),
856                thread("unanswered", ReviewStatus::Open, None),
857                thread("resolved", ReviewStatus::Resolved, Some("2026-07-20T09:00:00Z")),
858            ],
859        );
860
861        let (count, since) = ws.review_replies_waiting("forge", "feat").expect("answers waiting");
862        assert_eq!(count, 2, "only the answered-and-unread threads are owed a turn");
863        let expected = std::time::SystemTime::from(
864            time::OffsetDateTime::parse(
865                "2026-07-19T11:00:00Z",
866                &time::format_description::well_known::Rfc3339,
867            )
868            .expect("parse"),
869        );
870        assert_eq!(since, expected, "the wait is dated from the answer that has sat longest");
871
872        assert!(
873            ws.review_replies_waiting("forge", "other").is_none(),
874            "the tally is scoped to its own branch",
875        );
876    }
877
878    fn sweep_git(dir: &std::path::Path, args: &[&str]) {
879        let out =
880            std::process::Command::new("git").arg("-C").arg(dir).args(args).output().expect("git");
881        assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
882    }
883
884    /// A repo at `dir` on `branch`, with one commit so HEAD resolves.
885    /// `git init -b` needs git 2.28; CI runs 2.25, hence `symbolic-ref`.
886    fn sweep_init_repo(dir: &std::path::Path, branch: &str) {
887        sweep_git(dir, &["init", "-q"]);
888        sweep_git(dir, &["symbolic-ref", "HEAD", &format!("refs/heads/{branch}")]);
889        sweep_git(dir, &["config", "user.email", "test@example.com"]);
890        sweep_git(dir, &["config", "user.name", "Test"]);
891        sweep_git(dir, &["config", "commit.gpgsign", "false"]);
892        std::fs::write(dir.join("README.md"), "hi\n").expect("write");
893        sweep_git(dir, &["add", "."]);
894        sweep_git(dir, &["commit", "-q", "-m", "init"]);
895    }
896
897    /// `origin/HEAD` as a clone leaves it: a symref, not a plain ref.
898    /// The distinction is what the population count turns on.
899    fn sweep_symref_head(root: &std::path::Path, target: &str) {
900        sweep_git(root, &["update-ref", &format!("refs/remotes/origin/{target}"), "HEAD"]);
901        sweep_git(
902            root,
903            &["symbolic-ref", "refs/remotes/origin/HEAD", &format!("refs/remotes/origin/{target}")],
904        );
905    }
906
907    fn sweep_thread(id: &str) -> forge_primitives::review::ReviewThread {
908        use forge_primitives::review::{
909            ReviewAnchor, ReviewAuthor, ReviewComment, ReviewSide, ReviewStatus, ReviewThread,
910        };
911        ReviewThread {
912            id: id.to_owned(),
913            anchor: ReviewAnchor {
914                path: "src/x.rs".to_owned(),
915                side: ReviewSide::New,
916                line: 1,
917                content_hash: 1,
918                context: vec!["ctx".to_owned()],
919                base_ref: "main".to_owned(),
920            },
921            comments: vec![ReviewComment {
922                author: ReviewAuthor::User,
923                text: "look".to_owned(),
924                at: "2026-07-19T10:00:00Z".to_owned(),
925                review_id: None,
926            }],
927            status: ReviewStatus::Open,
928            created_at: "2026-07-19T10:00:00Z".to_owned(),
929            updated_at: "2026-07-19T10:00:00Z".to_owned(),
930            commit: None,
931        }
932    }
933
934    /// A workspace with the store open and one project rooted at `root`.
935    fn sweep_ws(
936        dir: &std::path::Path,
937        name: &str,
938        root: &std::path::Path,
939    ) -> (Arc<Workspace>, tokio::sync::mpsc::UnboundedReceiver<crate::SeatUpdate>) {
940        let (ws, rx) = Workspace::testing_stub_with_config_dir(dir.to_owned());
941        ws.install_db_for_test(crate::store::Db::open(&dir.join("db.redb")).expect("open db"));
942        ws.seed_test_project(name, &root.to_string_lossy());
943        (ws, rx)
944    }
945
946    fn sweep_has_state(ws: &Arc<Workspace>, project: &str, branch: &str) -> bool {
947        !ws.load_review_threads(project, branch).expect("load").is_empty()
948            || !ws.load_reviews(project, branch).expect("load").is_empty()
949    }
950
951    /// Rows outlive the worker that wrote them on purpose, so the only
952    /// thing that may clear them is the branch itself being gone. A
953    /// remote-tracking ref counts as present - the local head is routinely
954    /// deleted after a push while the PR is still open. And judgement is
955    /// exact membership, not a ref pattern: `fix` and `fix/renamed` cannot
956    /// coexist in git, so a stored `fix` beside a live `fix/renamed` means
957    /// `fix` was deleted, where a pattern lookup would have matched its
958    /// replacement and spared it forever.
959    #[test]
960    fn the_sweep_clears_dead_branches_and_spares_every_live_one() {
961        let dir = tempdir().expect("tempdir");
962        let root = dir.path().join("myproj");
963        std::fs::create_dir_all(&root).expect("mkdir");
964        sweep_init_repo(&root, "main");
965        for branch in ["feat/live", "feat/second", "feat/third", "fix/renamed"] {
966            sweep_git(&root, &["branch", branch]);
967        }
968        sweep_git(&root, &["update-ref", "refs/remotes/origin/feat/pushed", "HEAD"]);
969
970        let (ws, _rx) = sweep_ws(dir.path(), "myproj", &root);
971        let reviewer = SessionSlot::from_str_for_test("lead-uuid");
972        // Four live to three dead, so the pass stays under the refusal
973        // bound - a sweep is meant to be a trickle, and the ratio that
974        // trips the bound is asserted separately.
975        for branch in
976            ["feat/live", "feat/second", "feat/third", "feat/pushed", "feat/merged", "fix"]
977        {
978            seed(&ws, "myproj", branch, &[sweep_thread("a")]);
979            ws.submit_review("myproj", branch, None, &["a".to_owned()], reviewer.clone())
980                .expect("submit");
981        }
982        // Threads drafted but never submitted: no reviews row at all.
983        seed(&ws, "myproj", "feat/drafts", &[sweep_thread("d")]);
984
985        assert_eq!(ws.sweep_dead_review_branches(), 3, "feat/merged, fix and feat/drafts");
986
987        assert!(sweep_has_state(&ws, "myproj", "feat/live"), "a local head survives");
988        assert!(sweep_has_state(&ws, "myproj", "feat/second"), "so do the other live ones");
989        assert!(sweep_has_state(&ws, "myproj", "feat/third"), "so do the other live ones");
990        assert!(sweep_has_state(&ws, "myproj", "feat/pushed"), "a remote-tracking ref survives");
991        assert!(!sweep_has_state(&ws, "myproj", "feat/merged"), "no ref anywhere, cleared");
992        assert!(!sweep_has_state(&ws, "myproj", "fix"), "fix/renamed is not fix");
993        assert!(!sweep_has_state(&ws, "myproj", "feat/drafts"), "drafts go with their branch");
994    }
995
996    /// A store the sweep cannot check against is left alone. With no repo
997    /// there is nothing to be absent from, and reading that as absence
998    /// would clear every row the project has.
999    #[test]
1000    fn the_sweep_spares_a_project_it_cannot_check() {
1001        let dir = tempdir().expect("tempdir");
1002        let root = dir.path().join("plainproj");
1003        std::fs::create_dir_all(&root).expect("mkdir");
1004
1005        let (ws, _rx) = sweep_ws(dir.path(), "plainproj", &root);
1006        seed(&ws, "plainproj", "feat/x", &[sweep_thread("a")]);
1007
1008        assert_eq!(ws.sweep_dead_review_branches(), 0, "nothing to verify, nothing to clear");
1009        assert!(sweep_has_state(&ws, "plainproj", "feat/x"));
1010    }
1011
1012    /// The case no per-branch check can catch: git succeeds, the path is
1013    /// right, and the branches really are absent from the ref set it can
1014    /// see. A shallow or `--single-branch` clone knows two names - its own
1015    /// branch, and `HEAD` via `origin/HEAD` - so the ref count is what
1016    /// tells it apart from a large but genuine cleanup.
1017    #[test]
1018    fn the_sweep_refuses_a_repo_that_knows_almost_no_branches() {
1019        let dir = tempdir().expect("tempdir");
1020        let root = dir.path().join("recloned");
1021        std::fs::create_dir_all(&root).expect("mkdir");
1022        sweep_init_repo(&root, "main");
1023        sweep_symref_head(&root, "main");
1024
1025        let (ws, _rx) = sweep_ws(dir.path(), "recloned", &root);
1026        for branch in ["feat/a", "feat/b", "feat/c"] {
1027            seed(&ws, "recloned", branch, &[sweep_thread("a")]);
1028        }
1029
1030        assert_eq!(ws.sweep_dead_review_branches(), 0, "refused, not swept");
1031        for branch in ["feat/a", "feat/b", "feat/c"] {
1032            assert!(sweep_has_state(&ws, "recloned", branch), "{branch} kept");
1033        }
1034    }
1035
1036    /// **A doc scope is not a branch, and the sweep must not read it as a
1037    /// dead one.** The scope is stored in the branch column, so a document
1038    /// whose threads should survive a boot is exactly the row a naive
1039    /// sweep deletes: no repo has a ref named `doc:/Users/.../plan.md`.
1040    #[test]
1041    fn the_sweep_spares_a_doc_scope() {
1042        let dir = tempdir().expect("tempdir");
1043        let root = dir.path().join("docproj");
1044        std::fs::create_dir_all(&root).expect("mkdir");
1045        sweep_init_repo(&root, "main");
1046        for branch in ["feat/live", "feat/second", "feat/third"] {
1047            sweep_git(&root, &["branch", branch]);
1048        }
1049
1050        let (ws, _rx) = sweep_ws(dir.path(), "docproj", &root);
1051        for branch in ["feat/live", "feat/second", "feat/third"] {
1052            seed(&ws, "docproj", branch, &[sweep_thread("a")]);
1053        }
1054        let doc_scope = forge_primitives::review::doc_scope_ref("/Users/me/proj/docs/plan.md");
1055        seed(&ws, "docproj", &doc_scope, &[sweep_thread("d")]);
1056
1057        assert_eq!(
1058            ws.sweep_dead_review_branches(),
1059            0,
1060            "three live branches and a document: nothing is dead",
1061        );
1062        assert!(
1063            sweep_has_state(&ws, "docproj", &doc_scope),
1064            "the document's threads survive a boot",
1065        );
1066    }
1067
1068    /// **A scope does not count as a branch in the refusal's ratio** (the
1069    /// lead's gate, 2026-10-17): two dead branches against a repo that
1070    /// knows almost none is the shallow-clone shape the guard exists for,
1071    /// and scopes sharing the branch column must not water the majority
1072    /// down until it sweeps - which is exactly what they did: with the
1073    /// scopes counted, `4 > 4` failed and the guard stayed silent.
1074    #[test]
1075    fn the_sweep_refusal_counts_branch_entries_only() {
1076        let dir = tempdir().expect("tempdir");
1077        let root = dir.path().join("scoped");
1078        std::fs::create_dir_all(&root).expect("mkdir");
1079        sweep_init_repo(&root, "main");
1080        sweep_symref_head(&root, "main");
1081
1082        let (ws, _rx) = sweep_ws(dir.path(), "scoped", &root);
1083        for branch in ["feat/gone-a", "feat/gone-b"] {
1084            seed(&ws, "scoped", branch, &[sweep_thread("a")]);
1085        }
1086        let doc = forge_primitives::review::doc_scope_ref("/Users/me/proj/docs/plan.md");
1087        seed(&ws, "scoped", &doc, &[sweep_thread("d")]);
1088        let tree = forge_primitives::review::tree_scope_ref(&SessionSlot::from_str_for_test(
1089            "Busytools/forge/lead",
1090        ));
1091        seed(&ws, "scoped", &tree, &[sweep_thread("t")]);
1092
1093        assert_eq!(
1094            ws.sweep_dead_review_branches(),
1095            0,
1096            "every branch dead against a repo that knows almost none: refused",
1097        );
1098        for branch in ["feat/gone-a", "feat/gone-b"] {
1099            assert!(sweep_has_state(&ws, "scoped", branch), "{branch} kept");
1100        }
1101        assert!(sweep_has_state(&ws, "scoped", &doc), "and the scopes, of course");
1102        assert!(sweep_has_state(&ws, "scoped", &tree), "both of them");
1103    }
1104
1105    /// **A tree scope names a SEAT, not a ref, and the sweep must not read
1106    /// it as a dead branch.** A seat's tree threads follow the seat - a
1107    /// worker's worktree, the lead's checkout - so no repo ref will ever
1108    /// carry the scope's name; without the guard the sweep deletes the
1109    /// files page's review the first time it runs.
1110    #[test]
1111    fn the_sweep_spares_a_tree_scope() {
1112        let dir = tempdir().expect("tempdir");
1113        let root = dir.path().join("treews");
1114        std::fs::create_dir_all(&root).expect("mkdir");
1115        sweep_init_repo(&root, "main");
1116        sweep_git(&root, &["branch", "feat/live"]);
1117
1118        let (ws, _rx) = sweep_ws(dir.path(), "treews", &root);
1119        seed(&ws, "treews", "feat/live", &[sweep_thread("a")]);
1120        let slot = SessionSlot::worker("Busytools", "treews", "sweeper");
1121        let tree_scope = forge_primitives::review::tree_scope_ref(&slot);
1122        seed(&ws, "treews", &tree_scope, &[sweep_thread("t")]);
1123
1124        assert_eq!(
1125            ws.sweep_dead_review_branches(),
1126            0,
1127            "one live branch and a seat's tree: nothing is dead",
1128        );
1129        assert!(
1130            sweep_has_state(&ws, "treews", &tree_scope),
1131            "the seat's tree threads survive a boot",
1132        );
1133    }
1134
1135    /// The accumulation #598 exists to clear IS the dead set, so a bound
1136    /// keyed on the ratio alone would refuse hardest on the store it was
1137    /// written for. A repo full of refs is a real cleanup however lopsided
1138    /// the ratio.
1139    #[test]
1140    fn the_sweep_clears_a_long_backlog_when_the_repo_is_well_populated() {
1141        let dir = tempdir().expect("tempdir");
1142        let root = dir.path().join("busy");
1143        std::fs::create_dir_all(&root).expect("mkdir");
1144        sweep_init_repo(&root, "main");
1145        for branch in ["feat/live", "feat/second", "feat/third"] {
1146            sweep_git(&root, &["branch", branch]);
1147        }
1148
1149        let (ws, _rx) = sweep_ws(dir.path(), "busy", &root);
1150        // Eight long-merged branches against one live one: the ratio is
1151        // lopsided, the repo is not.
1152        for n in 0..8 {
1153            seed(&ws, "busy", &format!("feat/merged-{n}"), &[sweep_thread("a")]);
1154        }
1155        seed(&ws, "busy", "feat/live", &[sweep_thread("b")]);
1156
1157        assert_eq!(ws.sweep_dead_review_branches(), 8, "the backlog is what this is for");
1158        assert!(sweep_has_state(&ws, "busy", "feat/live"));
1159    }
1160
1161    /// Both halves are required. A repo that knows few branches is only
1162    /// refused when most of the store reads dead against it - one merged
1163    /// branch beside a live one is an ordinary tidy-up, and a small local
1164    /// repo is still allowed to have it swept.
1165    #[test]
1166    fn a_sparse_repo_still_sweeps_when_only_a_minority_reads_dead() {
1167        let dir = tempdir().expect("tempdir");
1168        let root = dir.path().join("small");
1169        std::fs::create_dir_all(&root).expect("mkdir");
1170        sweep_init_repo(&root, "main");
1171        sweep_symref_head(&root, "main");
1172
1173        let (ws, _rx) = sweep_ws(dir.path(), "small", &root);
1174        seed(&ws, "small", "main", &[sweep_thread("a")]);
1175        seed(&ws, "small", "feat/merged", &[sweep_thread("b")]);
1176
1177        assert_eq!(ws.sweep_dead_review_branches(), 1, "one of two dead is not a majority");
1178        assert!(sweep_has_state(&ws, "small", "main"), "the live branch keeps its state");
1179        assert!(!sweep_has_state(&ws, "small", "feat/merged"));
1180    }
1181
1182    /// Where the threshold sits, and that a symref is not counted toward
1183    /// it. Both poles use local heads only, so `names` and `ref_count`
1184    /// coincide here - this pins the position, NOT which of the two the
1185    /// guard reads; the slash test below is the only thing that does
1186    /// that.
1187    #[test]
1188    fn the_refusal_threshold_sits_between_two_and_three_branch_refs() {
1189        let scenario = |name: &str, extra_branches: usize| {
1190            let dir = tempdir().expect("tempdir");
1191            let root = dir.path().join(name);
1192            std::fs::create_dir_all(&root).expect("mkdir");
1193            sweep_init_repo(&root, "main");
1194            sweep_symref_head(&root, "main");
1195            for n in 0..extra_branches {
1196                sweep_git(&root, &["branch", &format!("feat/keep-{n}")]);
1197            }
1198            let (ws, _rx) = sweep_ws(dir.path(), name, &root);
1199            for branch in ["feat/a", "feat/b"] {
1200                seed(&ws, name, branch, &[sweep_thread("a")]);
1201            }
1202            (dir, ws.sweep_dead_review_branches())
1203        };
1204        // The clone shape alone is two refs - the local head and the one
1205        // remote-tracking branch its symref points at. Under the bound.
1206        let (_d1, under) = scenario("bare", 0);
1207        assert_eq!(under, 0, "two branch refs is not enough to trust a majority-dead pass");
1208        // One more local head makes three, and the same ratio goes through.
1209        let (_d2, over) = scenario("populated", 1);
1210        assert_eq!(over, 2, "at three branch refs the same ratio sweeps");
1211    }
1212
1213    /// The guard has to survive a rename. Each slash in a branch name
1214    /// adds a phantom suffix to the over-including name set, so
1215    /// `release/2.0/rc1` contributes `2.0/rc1` and `rc1` on top of
1216    /// itself: a guard reading that set as a population sees three where
1217    /// the clone holds two refs, stops firing, and deletes. The count is
1218    /// of refs and does not move when a branch is renamed.
1219    ///
1220    /// Two slash levels, not one - at one the name set and the ref count
1221    /// are both two, and the test passes whichever quantity the guard
1222    /// reads.
1223    #[test]
1224    fn a_slash_in_a_branch_name_does_not_switch_the_refusal_off() {
1225        let shallow_clone_shaped = |name: &str, default: &str| {
1226            let dir = tempdir().expect("tempdir");
1227            let root = dir.path().join(name);
1228            std::fs::create_dir_all(&root).expect("mkdir");
1229            sweep_init_repo(&root, default);
1230            // What `git clone --depth 1` leaves: the local head, one
1231            // remote-tracking ref, and origin/HEAD.
1232            sweep_git(&root, &["update-ref", &format!("refs/remotes/origin/{default}"), "HEAD"]);
1233            sweep_symref_head(&root, default);
1234            let (ws, _rx) = sweep_ws(dir.path(), name, &root);
1235            for branch in ["feat/a", "feat/b"] {
1236                seed(&ws, name, branch, &[sweep_thread("a")]);
1237            }
1238            (dir, ws.sweep_dead_review_branches())
1239        };
1240        let (_d1, plain) = shallow_clone_shaped("plain", "main");
1241        assert_eq!(plain, 0, "the shallow clone is refused");
1242        let (_d2, slashed) = shallow_clone_shaped("slashed", "release/2.0/rc1");
1243        assert_eq!(slashed, 0, "and renaming its default branch does not turn the guard off");
1244    }
1245
1246    /// The boot wave and the kick dispatcher are creating worker worktrees
1247    /// at the moment the sweep runs, so a registered worker's branch being
1248    /// absent right now is routine rather than evidence it is gone.
1249    #[test]
1250    fn the_sweep_spares_a_branch_a_live_worker_has_yet_to_create() {
1251        let dir = tempdir().expect("tempdir");
1252        let root = dir.path().join("myproj");
1253        std::fs::create_dir_all(&root).expect("mkdir");
1254        sweep_init_repo(&root, "main");
1255
1256        let (ws, _rx) = sweep_ws(dir.path(), "myproj", &root);
1257        let project_key =
1258            ws.list_projects().into_iter().find(|v| v.name == "myproj").expect("project").key;
1259        ws.insert_live_worker(
1260            &project_key,
1261            crate::WorkerEntry {
1262                label: "reviewer".to_owned(),
1263                charter: "review".to_owned(),
1264                slot: SessionSlot::from_str_for_test("worker-uuid"),
1265                session_id: None,
1266                status: forge_primitives::WorkerLiveness::Spawning,
1267                spawned_at: std::time::SystemTime::UNIX_EPOCH,
1268                spawned_by: SessionSlot::from_str_for_test("lead"),
1269                needs_tag: false,
1270                is_git_repo_at_spawn: true,
1271                cwd: None,
1272                diagnostic: None,
1273                kick: None,
1274            },
1275        );
1276        seed(&ws, "myproj", "worktree-reviewer", &[sweep_thread("a")]);
1277
1278        assert_eq!(ws.sweep_dead_review_branches(), 0, "its worktree is still being created");
1279        assert!(sweep_has_state(&ws, "myproj", "worktree-reviewer"));
1280    }
1281
1282    #[test]
1283    fn submit_review_files_listed_threads_through_the_workspace() {
1284        use forge_primitives::review::{
1285            ReviewAnchor, ReviewAuthor, ReviewComment, ReviewSide, ReviewStatus, ReviewThread,
1286        };
1287        let make = |id: &str| ReviewThread {
1288            id: id.to_owned(),
1289            anchor: ReviewAnchor {
1290                path: "src/x.rs".to_owned(),
1291                side: ReviewSide::New,
1292                line: 1,
1293                content_hash: 1,
1294                context: vec!["ctx".to_owned()],
1295                base_ref: "main".to_owned(),
1296            },
1297            comments: vec![ReviewComment {
1298                author: ReviewAuthor::User,
1299                text: format!("c{id}"),
1300                at: "2026-07-23T10:00:00Z".to_owned(),
1301                review_id: None,
1302            }],
1303            status: ReviewStatus::Open,
1304            created_at: "2026-07-23T10:00:00Z".to_owned(),
1305            updated_at: "2026-07-23T10:00:00Z".to_owned(),
1306            commit: None,
1307        };
1308
1309        let dir = tempdir().expect("tempdir");
1310        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1311        ws.install_db_for_test(
1312            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
1313        );
1314
1315        assert!(ws.load_reviews("forge", "feat").expect("load").is_empty(), "empty on miss");
1316        seed(&ws, "forge", "feat", &[make("a"), make("b"), make("c")]);
1317
1318        let filed = |id: &str| {
1319            ws.load_review_threads("forge", "feat")
1320                .expect("load")
1321                .into_iter()
1322                .find(|t| t.id == id)
1323                .expect("thread")
1324                .origin_review()
1325                .map(str::to_owned)
1326        };
1327
1328        let origin = SessionSlot::from_str_for_test("reviewer");
1329        let r1 = ws
1330            .submit_review(
1331                "forge",
1332                "feat",
1333                Some("first".to_owned()),
1334                &["a".to_owned(), "b".to_owned()],
1335                origin.clone(),
1336            )
1337            .expect("submit mints a review");
1338        assert_eq!(r1.number, 1);
1339        assert_eq!(filed("a"), Some(r1.id.clone()), "a filed");
1340        assert_eq!(filed("b"), Some(r1.id.clone()), "b filed");
1341        assert_eq!(filed("c"), None, "c unlisted stays unfiled");
1342        assert_eq!(ws.load_reviews("forge", "feat").expect("load").len(), 1);
1343
1344        let r2 =
1345            ws.submit_review("forge", "feat", None, &["c".to_owned()], origin).expect("submit 2");
1346        assert_eq!(r2.number, 2, "the number increments");
1347        assert_eq!(filed("a"), Some(r1.id.clone()), "a stays in r1");
1348        assert_eq!(filed("c"), Some(r2.id), "c filed into r2");
1349    }
1350
1351    #[test]
1352    fn review_conversation_methods_round_trip_through_the_workspace() {
1353        use forge_primitives::review::{
1354            ReviewAnchor, ReviewAuthor, ReviewComment, ReviewSide, ReviewStatus, ReviewThread,
1355        };
1356        let make = |id: &str| ReviewThread {
1357            id: id.to_owned(),
1358            anchor: ReviewAnchor {
1359                path: "src/x.rs".to_owned(),
1360                side: ReviewSide::New,
1361                line: 12,
1362                content_hash: 1,
1363                context: vec!["fn f() {".to_owned()],
1364                base_ref: "main".to_owned(),
1365            },
1366            comments: vec![ReviewComment {
1367                author: ReviewAuthor::User,
1368                text: format!("look at {id}"),
1369                at: "2026-07-23T10:00:00Z".to_owned(),
1370                review_id: None,
1371            }],
1372            status: ReviewStatus::Open,
1373            created_at: "2026-07-23T10:00:00Z".to_owned(),
1374            updated_at: "2026-07-23T10:00:00Z".to_owned(),
1375            commit: None,
1376        };
1377
1378        let dir = tempdir().expect("tempdir");
1379        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1380        ws.install_db_for_test(
1381            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
1382        );
1383        seed(&ws, "forge", "feat", &[make("a"), make("b")]);
1384        let caller = SessionSlot::from_str_for_test("worker");
1385        let r1 = ws
1386            .submit_review(
1387                "forge",
1388                "feat",
1389                Some("overview".to_owned()),
1390                &["a".to_owned(), "b".to_owned()],
1391                SessionSlot::from_str_for_test("reviewer"),
1392            )
1393            .expect("submit");
1394
1395        let list = ws.review_list("forge", "feat").expect("list");
1396        assert_eq!(list.len(), 1);
1397        assert_eq!(list[0].review_id, r1.id);
1398        assert_eq!(list[0].comment_count, 2);
1399        assert_eq!(list[0].open, 2);
1400
1401        let detail = ws.review_get("forge", "feat", &r1.id).expect("get").expect("review present");
1402        assert_eq!(detail.comments.len(), 2);
1403        assert_eq!(detail.summary.as_deref(), Some("overview"));
1404        assert!(!detail.comments[0].context.is_empty(), "anchor context is handed back");
1405
1406        let status = ws
1407            .review_reply(
1408                &caller,
1409                "forge",
1410                "feat",
1411                "a",
1412                "implementer",
1413                "fixed",
1414                "2026-07-23T12:00:00Z",
1415            )
1416            .expect("reply");
1417        assert_eq!(status.status, "addressed", "a reply flips Open -> Addressed");
1418        let list = ws.review_list("forge", "feat").expect("list");
1419        assert_eq!((list[0].open, list[0].addressed), (1, 1));
1420
1421        ws.review_resolve(&caller, "forge", "feat", "a").expect("resolve");
1422        let list = ws.review_list("forge", "feat").expect("list");
1423        assert_eq!((list[0].addressed, list[0].resolved), (0, 1));
1424
1425        // An unknown / cross-branch comment id is rejected, not a no-op -
1426        // for reply (append_reply path) and resolve (set_status path) alike.
1427        assert!(ws.review_reply(&caller, "forge", "feat", "missing", "x", "y", "z").is_err());
1428        assert!(ws.review_resolve(&caller, "forge", "feat", "missing").is_err());
1429        assert!(
1430            ws.review_reply(&caller, "forge", "other", "a", "x", "y", "z").is_err(),
1431            "reply: a lives on feat, not other",
1432        );
1433        assert!(
1434            ws.review_resolve(&caller, "forge", "other", "a").is_err(),
1435            "resolve: a lives on feat, not other",
1436        );
1437    }
1438
1439    /// A reply and a resolve both answer with the comment's anchor and the
1440    /// review round its turn is filed in, so the caller can name the spot
1441    /// and the round instead of only the comment id.
1442    ///
1443    /// **`c-71` spans rounds and the branch runs on past it**: it is filed
1444    /// in rounds 1 and 2, and a third round then stamps nothing (`c-1`'s
1445    /// only turn is already filed), so the newest round exists and no
1446    /// comment is in it. The number must be the round this comment's
1447    /// LATEST turn is filed in (2) - the thread's first round reads 1 and
1448    /// the branch's newest reads 3, and both are wrong here. Its side is
1449    /// Old while the other fixture is New, so a hardcoded side fails too.
1450    #[test]
1451    fn reply_and_resolve_echo_the_comment_anchor() {
1452        use forge_primitives::review::{
1453            ReviewAnchor, ReviewAuthor, ReviewComment, ReviewSide, ReviewStatus, ReviewThread,
1454        };
1455        let thread = |id: &str, path: &str, line: u32, side: ReviewSide| ReviewThread {
1456            id: id.to_owned(),
1457            anchor: ReviewAnchor {
1458                path: path.to_owned(),
1459                side,
1460                line,
1461                content_hash: 1,
1462                context: vec!["const glyph = ...".to_owned()],
1463                base_ref: "main".to_owned(),
1464            },
1465            comments: vec![ReviewComment {
1466                author: ReviewAuthor::User,
1467                text: format!("look at {id}"),
1468                at: "2026-07-23T10:00:00Z".to_owned(),
1469                review_id: None,
1470            }],
1471            status: ReviewStatus::Open,
1472            created_at: "2026-07-23T10:00:00Z".to_owned(),
1473            updated_at: "2026-07-23T10:00:00Z".to_owned(),
1474            commit: None,
1475        };
1476        let dir = tempdir().expect("tempdir");
1477        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1478        ws.install_db_for_test(
1479            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
1480        );
1481        seed(
1482            &ws,
1483            "forge",
1484            "feat",
1485            &[
1486                thread("c-1", "src/other.rs", 3, ReviewSide::New),
1487                thread("c-71", "src/chat/units.ts", 919, ReviewSide::Old),
1488            ],
1489        );
1490        let reviewer = SessionSlot::from_str_for_test("reviewer");
1491        ws.submit_review(
1492            "forge",
1493            "feat",
1494            None,
1495            &["c-1".to_owned(), "c-71".to_owned()],
1496            reviewer.clone(),
1497        )
1498        .expect("first round seals both");
1499        // A fresh user turn on c-71, so the second round has something to
1500        // seal and the thread spans two rounds.
1501        let mut threads = ws.load_review_threads("forge", "feat").expect("load");
1502        let c71 = threads.iter_mut().find(|t| t.id == "c-71").expect("c-71 is there");
1503        c71.comments.push(ReviewComment {
1504            author: ReviewAuthor::User,
1505            text: "still unguarded".to_owned(),
1506            at: "2026-07-24T10:00:00Z".to_owned(),
1507            review_id: None,
1508        });
1509        seed(&ws, "forge", "feat", &threads);
1510        ws.submit_review("forge", "feat", None, &["c-71".to_owned()], reviewer.clone())
1511            .expect("second round");
1512        ws.submit_review("forge", "feat", None, &["c-1".to_owned()], reviewer)
1513            .expect("third round, c-71 in neither the first nor the last");
1514        let caller = SessionSlot::from_str_for_test("worker");
1515
1516        let replied = ws
1517            .review_reply(&caller, "forge", "feat", "c-71", "implementer", "replay-only", "t")
1518            .expect("reply");
1519        assert_eq!(replied.comment_id, "c-71");
1520        assert_eq!(replied.file, "src/chat/units.ts", "the file the comment is anchored to");
1521        assert_eq!(replied.line, 919);
1522        assert_eq!(replied.side, "old", "the side the anchor holds, not a default");
1523        assert_eq!(replied.number, Some(2), "the round this comment's latest turn is filed in");
1524        assert_eq!(replied.status, "addressed");
1525
1526        let resolved = ws.review_resolve(&caller, "forge", "feat", "c-71").expect("resolve");
1527        assert_eq!(resolved.comment_id, "c-71");
1528        assert_eq!(resolved.file, "src/chat/units.ts", "the anchor survives the resolve");
1529        assert_eq!(resolved.line, 919);
1530        assert_eq!(resolved.side, "old");
1531        assert_eq!(resolved.number, Some(2));
1532        assert_eq!(resolved.status, "resolved");
1533    }
1534
1535    /// A corrupt reviews row refuses the reply BEFORE the write lands: the
1536    /// thread is found (the read that can fail is the reviews side), so
1537    /// this is the one arrangement where read-before-write and
1538    /// write-before-read differ. If the read ran after the append, this
1539    /// reply would be reported failed while it had already landed, and a
1540    /// retry would duplicate it.
1541    #[test]
1542    fn a_reply_whose_anchor_cannot_be_read_lands_nothing() {
1543        use forge_primitives::review::{
1544            ReviewAnchor, ReviewAuthor, ReviewComment, ReviewSide, ReviewStatus, ReviewThread,
1545        };
1546        let dir = tempdir().expect("tempdir");
1547        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1548        ws.install_db_for_test(
1549            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
1550        );
1551        seed(
1552            &ws,
1553            "forge",
1554            "feat",
1555            &[ReviewThread {
1556                id: "c-1".to_owned(),
1557                anchor: ReviewAnchor {
1558                    path: "src/x.rs".to_owned(),
1559                    side: ReviewSide::New,
1560                    line: 1,
1561                    content_hash: 1,
1562                    context: vec!["ctx".to_owned()],
1563                    base_ref: "main".to_owned(),
1564                },
1565                comments: vec![ReviewComment {
1566                    author: ReviewAuthor::User,
1567                    text: "look here".to_owned(),
1568                    at: "2026-07-23T10:00:00Z".to_owned(),
1569                    review_id: None,
1570                }],
1571                status: ReviewStatus::Open,
1572                created_at: "2026-07-23T10:00:00Z".to_owned(),
1573                updated_at: "2026-07-23T10:00:00Z".to_owned(),
1574                commit: None,
1575            }],
1576        );
1577        ws.submit_review(
1578            "forge",
1579            "feat",
1580            None,
1581            &["c-1".to_owned()],
1582            SessionSlot::from_str_for_test("reviewer"),
1583        )
1584        .expect("a reviews row exists to corrupt");
1585        {
1586            let guard = ws.db.lock();
1587            crate::store::review::write_corrupt_reviews_row_for_test(
1588                guard.as_ref().expect("db installed"),
1589                "forge",
1590                "feat",
1591            )
1592            .expect("corrupt the reviews row");
1593        }
1594        let caller = SessionSlot::from_str_for_test("worker");
1595
1596        let err = ws
1597            .review_reply(&caller, "forge", "feat", "c-1", "implementer", "fixed", "t")
1598            .expect_err("the anchor's read fails, so the reply is refused");
1599
1600        assert!(!err.is_empty(), "the failure is reported: {err}");
1601        let threads = ws.load_review_threads("forge", "feat").expect("the threads row still reads");
1602        assert_eq!(
1603            threads[0].comments.len(),
1604            1,
1605            "nothing landed behind the refused read, so a retry duplicates nothing",
1606        );
1607        assert_eq!(threads[0].status, ReviewStatus::Open, "and the status is untouched");
1608    }
1609
1610    /// A comment filed in no review answers a null number through the real
1611    /// store path, not only at the tool's mock: there is no round to name
1612    /// yet, and the key is still the shape every answer carries.
1613    #[test]
1614    fn an_unfiled_comment_answers_a_null_number() {
1615        use forge_primitives::review::{
1616            ReviewAnchor, ReviewAuthor, ReviewComment, ReviewSide, ReviewStatus, ReviewThread,
1617        };
1618        let dir = tempdir().expect("tempdir");
1619        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1620        ws.install_db_for_test(
1621            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
1622        );
1623        seed(
1624            &ws,
1625            "forge",
1626            "feat",
1627            &[ReviewThread {
1628                id: "c-9".to_owned(),
1629                anchor: ReviewAnchor {
1630                    path: "src/x.rs".to_owned(),
1631                    side: ReviewSide::New,
1632                    line: 1,
1633                    content_hash: 1,
1634                    context: vec!["ctx".to_owned()],
1635                    base_ref: "main".to_owned(),
1636                },
1637                comments: vec![ReviewComment {
1638                    author: ReviewAuthor::User,
1639                    text: "unfiled".to_owned(),
1640                    at: "2026-07-23T10:00:00Z".to_owned(),
1641                    review_id: None,
1642                }],
1643                status: ReviewStatus::Open,
1644                created_at: "2026-07-23T10:00:00Z".to_owned(),
1645                updated_at: "2026-07-23T10:00:00Z".to_owned(),
1646                commit: None,
1647            }],
1648        );
1649        let caller = SessionSlot::from_str_for_test("worker");
1650
1651        let replied = ws
1652            .review_reply(&caller, "forge", "feat", "c-9", "implementer", "x", "t")
1653            .expect("a reply on an unfiled thread still lands");
1654
1655        assert_eq!(replied.comment_id, "c-9");
1656        assert_eq!(replied.number, None, "no review is filed yet, so there is no round to name");
1657        assert_eq!(replied.status, "addressed");
1658    }
1659
1660    #[test]
1661    fn drain_review_activity_batches_one_notice_per_review_routed_to_origin() {
1662        use forge_primitives::review::{
1663            ReviewAnchor, ReviewAuthor, ReviewComment, ReviewSide, ReviewStatus, ReviewThread,
1664        };
1665        let make = |id: &str| ReviewThread {
1666            id: id.to_owned(),
1667            anchor: ReviewAnchor {
1668                path: "src/x.rs".to_owned(),
1669                side: ReviewSide::New,
1670                line: 1,
1671                content_hash: 1,
1672                context: vec!["ctx".to_owned()],
1673                base_ref: "main".to_owned(),
1674            },
1675            comments: vec![ReviewComment {
1676                author: ReviewAuthor::User,
1677                text: format!("look at {id}"),
1678                at: "2026-07-23T10:00:00Z".to_owned(),
1679                review_id: None,
1680            }],
1681            status: ReviewStatus::Open,
1682            created_at: "2026-07-23T10:00:00Z".to_owned(),
1683            updated_at: "2026-07-23T10:00:00Z".to_owned(),
1684            commit: None,
1685        };
1686        let dir = tempdir().expect("tempdir");
1687        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1688        ws.install_db_for_test(
1689            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
1690        );
1691        seed(&ws, "forge", "feat", &[make("a"), make("b"), make("c")]);
1692        let reviewer = SessionSlot::from_str_for_test("reviewer");
1693        let worker = SessionSlot::from_str_for_test("worker");
1694        ws.submit_review(
1695            "forge",
1696            "feat",
1697            None,
1698            &["a".to_owned(), "b".to_owned(), "c".to_owned()],
1699            reviewer.clone(),
1700        )
1701        .expect("submit");
1702
1703        // Nothing before the worker acts.
1704        assert!(ws.drain_review_activity(&worker).is_empty(), "no activity, no notice");
1705
1706        // Worker replies to one comment and resolves another in one turn,
1707        // leaving the third (c) untouched so an open count survives.
1708        ws.review_reply(&worker, "forge", "feat", "a", "impl", "fixed a", "t").expect("reply a");
1709        ws.review_resolve(&worker, "forge", "feat", "b").expect("resolve b");
1710
1711        let notices = ws.drain_review_activity(&worker);
1712        assert_eq!(notices.len(), 1, "one batched notice for the single touched review");
1713        match &notices[0] {
1714            SessionUpdate::ReviewActivityNotice { key, branch, waiting, message } => {
1715                assert_eq!(key, &reviewer, "routed to the submit origin, not the worker");
1716                assert_eq!(branch, "feat", "the notice names the branch it is about");
1717                // a -> Addressed (1 replied), b -> Resolved (1 resolved), c untouched (1 open).
1718                assert_eq!(*waiting, 1, "only the replied-to thread awaits the reviewer");
1719                assert!(message.contains("1 replied"), "tally counts the reply: {message}");
1720                assert!(message.contains("1 resolved"), "tally counts the resolve: {message}");
1721                assert!(
1722                    message.contains("1 open"),
1723                    "tally reflects the store's open count: {message}"
1724                );
1725            }
1726            other => panic!("expected ReviewActivityNotice, got {other:?}"),
1727        }
1728        // The buffer is drained - a second flush is empty.
1729        assert!(ws.drain_review_activity(&worker).is_empty(), "the turn buffer drained");
1730    }
1731
1732    /// A review's notice target is the SLOT the review was submitted
1733    /// from, never the caller whose turn drained it. The worker replies
1734    /// on its own slot, and routing the notice there would put "the
1735    /// worker addressed review #N" into the worker's own chat.
1736    ///
1737    /// Before this change `review_origin` held the submitter's session
1738    /// id, and nothing corrected it when that id changed: a reviewer
1739    /// that ran `/new` between submitting and the worker's reply carried
1740    /// an id no bucket held, so the batched notice was routed onto
1741    /// nothing and dropped silently.
1742    #[test]
1743    fn a_review_notice_routes_by_slot_not_by_the_id_the_reviewer_started_under() {
1744        use forge_primitives::review::{
1745            ReviewAnchor, ReviewAuthor, ReviewComment, ReviewSide, ReviewStatus, ReviewThread,
1746        };
1747        let thread = ReviewThread {
1748            id: "a".to_owned(),
1749            anchor: ReviewAnchor {
1750                path: "src/x.rs".to_owned(),
1751                side: ReviewSide::New,
1752                line: 1,
1753                content_hash: 1,
1754                context: vec!["ctx".to_owned()],
1755                base_ref: "main".to_owned(),
1756            },
1757            comments: vec![ReviewComment {
1758                author: ReviewAuthor::User,
1759                text: "look at a".to_owned(),
1760                at: "2026-07-23T10:00:00Z".to_owned(),
1761                review_id: None,
1762            }],
1763            status: ReviewStatus::Open,
1764            created_at: "2026-07-23T10:00:00Z".to_owned(),
1765            updated_at: "2026-07-23T10:00:00Z".to_owned(),
1766            commit: None,
1767        };
1768        let dir = tempdir().expect("tempdir");
1769        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1770        ws.install_db_for_test(
1771            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
1772        );
1773        seed(&ws, "forge", "feat", &[thread]);
1774        let reviewer = SessionSlot::worker("TestOrg", "test-project", "reviewer");
1775        let worker = SessionSlot::worker("TestOrg", "test-project", "worker");
1776        ws.submit_review("forge", "feat", None, &["a".to_owned()], reviewer.clone())
1777            .expect("submit");
1778
1779        ws.review_reply(&worker, "forge", "feat", "a", "impl", "fixed", "t").expect("reply");
1780        let notices = ws.drain_review_activity(&worker);
1781        assert_eq!(notices.len(), 1, "the reply still produces a notice");
1782        match &notices[0] {
1783            SessionUpdate::ReviewActivityNotice { key, .. } => {
1784                assert_eq!(key, &reviewer, "routed to the slot the review was submitted from");
1785            }
1786            other => panic!("expected ReviewActivityNotice, got {other:?}"),
1787        }
1788    }
1789
1790    #[test]
1791    fn drain_drops_notice_when_no_submit_origin_recorded() {
1792        use forge_primitives::review::{
1793            ReviewAnchor, ReviewAuthor, ReviewComment, ReviewSet, ReviewSide, ReviewStatus,
1794            ReviewThread,
1795        };
1796        // Post-restart shape: a thread already filed into a review on disk
1797        // (review_id set) + its reviews row, but `review_origin` is empty
1798        // (in-memory, cleared by the restart) because we seed the store
1799        // directly instead of calling submit_review. A worker reply must
1800        // then drain to NO notice - never mis-routed to the worker.
1801        let dir = tempdir().expect("tempdir");
1802        let db = crate::store::Db::open(&dir.path().join("db.redb")).expect("open db");
1803        let thread = ReviewThread {
1804            id: "a".to_owned(),
1805            anchor: ReviewAnchor {
1806                path: "src/x.rs".to_owned(),
1807                side: ReviewSide::New,
1808                line: 1,
1809                content_hash: 1,
1810                context: vec!["ctx".to_owned()],
1811                base_ref: "main".to_owned(),
1812            },
1813            comments: vec![ReviewComment {
1814                author: ReviewAuthor::User,
1815                text: "look".to_owned(),
1816                at: "2026-07-23T10:00:00Z".to_owned(),
1817                review_id: None,
1818            }],
1819            status: ReviewStatus::Open,
1820            created_at: "2026-07-23T10:00:00Z".to_owned(),
1821            updated_at: "2026-07-23T10:00:00Z".to_owned(),
1822            commit: None,
1823        };
1824        crate::store::review::save(&db, "forge", "feat", &[thread]).expect("seed threads");
1825        crate::store::review::save_reviews(
1826            &db,
1827            "forge",
1828            "feat",
1829            &[ReviewSet {
1830                id: "r1".to_owned(),
1831                number: 1,
1832                summary: None,
1833                created_at: "2026-07-23T10:00:00Z".to_owned(),
1834            }],
1835        )
1836        .expect("seed reviews");
1837
1838        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1839        ws.install_db_for_test(db);
1840        let worker = SessionSlot::from_str_for_test("worker");
1841        ws.review_reply(&worker, "forge", "feat", "a", "impl", "fixed", "t").expect("reply");
1842
1843        assert!(
1844            ws.drain_review_activity(&worker).is_empty(),
1845            "with no recorded origin the notice is dropped, not mis-routed to the worker",
1846        );
1847    }
1848
1849    #[test]
1850    fn submit_and_drain_do_not_deadlock_under_concurrency() {
1851        // The two threads' own progress, which the guard below reads. Statics
1852        // rather than captured handles so both threads reach them without a
1853        // clone each, and reset here so a re-run in one process starts clean.
1854        static SUBMITS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
1855        static DRAINS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
1856        use forge_primitives::review::{
1857            ReviewAnchor, ReviewAuthor, ReviewComment, ReviewSide, ReviewStatus, ReviewThread,
1858        };
1859        let make = |id: &str| ReviewThread {
1860            id: id.to_owned(),
1861            anchor: ReviewAnchor {
1862                path: "src/x.rs".to_owned(),
1863                side: ReviewSide::New,
1864                line: 1,
1865                content_hash: 1,
1866                context: vec!["ctx".to_owned()],
1867                base_ref: "main".to_owned(),
1868            },
1869            comments: vec![ReviewComment {
1870                author: ReviewAuthor::User,
1871                text: format!("look at {id}"),
1872                at: "2026-07-23T10:00:00Z".to_owned(),
1873                review_id: None,
1874            }],
1875            status: ReviewStatus::Open,
1876            created_at: "2026-07-23T10:00:00Z".to_owned(),
1877            updated_at: "2026-07-23T10:00:00Z".to_owned(),
1878            commit: None,
1879        };
1880        let dir = tempdir().expect("tempdir");
1881        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1882        ws.install_db_for_test(
1883            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
1884        );
1885        seed(&ws, "forge", "feat", &[make("a"), make("b"), make("c")]);
1886        let reviewer = SessionSlot::from_str_for_test("reviewer");
1887        let worker = SessionSlot::from_str_for_test("worker");
1888        ws.submit_review(
1889            "forge",
1890            "feat",
1891            None,
1892            &["a".to_owned(), "b".to_owned(), "c".to_owned()],
1893            reviewer.clone(),
1894        );
1895
1896        // Hammer the two lock-ordering-sensitive paths from separate threads:
1897        // the view submit path (`db` then `review_origin`) and the worker
1898        // turn-end path (`review_origin` after `db`). A regression that
1899        // reintroduces the opposite order deadlocks; each thread counts its
1900        // own iterations so the guard below can tell that deadlock from a
1901        // machine that is merely slow.
1902        SUBMITS.store(0, std::sync::atomic::Ordering::Relaxed);
1903        DRAINS.store(0, std::sync::atomic::Ordering::Relaxed);
1904        let (tx, rx) = std::sync::mpsc::channel();
1905        let ws_submit = ws.clone();
1906        let reviewer2 = reviewer.clone();
1907        let ws_drain = ws.clone();
1908        let worker2 = worker.clone();
1909        let coordinator = std::thread::spawn(move || {
1910            let submitter = std::thread::spawn(move || {
1911                for _ in 0..300 {
1912                    ws_submit.submit_review("forge", "feat", None, &[], reviewer2.clone());
1913                    SUBMITS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1914                }
1915            });
1916            let drainer = std::thread::spawn(move || {
1917                for _ in 0..300 {
1918                    let _ = ws_drain.review_reply(&worker2, "forge", "feat", "a", "impl", "x", "t");
1919                    let _ = ws_drain.drain_review_activity(&worker2);
1920                    DRAINS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1921                }
1922            });
1923            let _ = submitter.join();
1924            let _ = drainer.join();
1925            let _ = tx.send(());
1926        });
1927        // **The guard is progress, not a clock.** A healthy run of this test
1928        // takes seconds unloaded and eleven-plus under the load a parallel
1929        // gate brings, and a wall-clock budget read that as a deadlock: the
1930        // two firings that filed #1921 ran to the 20s watchdog line (20.75s
1931        // with setup and unwind in the log). Both threads count, and only a
1932        // spell in which NEITHER has advanced is a deadlock: an inversion
1933        // stops both counters, and no saturated run of the guard's own
1934        // campaign (50 loaded repetitions) produced a no-advance spell.
1935        let step = std::time::Duration::from_millis(200);
1936        let quiet = std::time::Duration::from_secs(10);
1937        let ceiling = std::time::Duration::from_secs(90);
1938        let started = std::time::Instant::now();
1939        let mut seen = (
1940            SUBMITS.load(std::sync::atomic::Ordering::Relaxed),
1941            DRAINS.load(std::sync::atomic::Ordering::Relaxed),
1942        );
1943        let mut still_since = std::time::Instant::now();
1944        while seen.0 < 300 || seen.1 < 300 {
1945            if rx.try_recv().is_ok() {
1946                break;
1947            }
1948            std::thread::sleep(step);
1949            let now = (
1950                SUBMITS.load(std::sync::atomic::Ordering::Relaxed),
1951                DRAINS.load(std::sync::atomic::Ordering::Relaxed),
1952            );
1953            if now != seen {
1954                seen = now;
1955                still_since = std::time::Instant::now();
1956            } else if still_since.elapsed() >= quiet {
1957                panic!(
1958                    "submit_review + drain_review_activity deadlocked (AB-BA on db vs \
1959                     review_origin): submitted {}/300, drained {}/300, and neither moved for \
1960                     {quiet:?}",
1961                    seen.0, seen.1,
1962                );
1963            }
1964            assert!(
1965                started.elapsed() < ceiling,
1966                "the two paths neither finished nor stalled: submitted {}/300, drained {}/300",
1967                seen.0,
1968                seen.1,
1969            );
1970        }
1971        let _ = coordinator.join();
1972        // A worker thread that panicked ends its loop short of 300, and its
1973        // join result is discarded above - so without this, a panicking
1974        // submitter or drainer would read as a pass.
1975        assert_eq!(
1976            (
1977                SUBMITS.load(std::sync::atomic::Ordering::Relaxed),
1978                DRAINS.load(std::sync::atomic::Ordering::Relaxed),
1979            ),
1980            (300, 300),
1981            "both threads ran to the end",
1982        );
1983    }
1984}