All of lore.kernel.org
 help / color / mirror / Atom feed
* [PATCH RFC v2 00/25] review: track and browse every version of a tracked series
@ 2026-08-12 21:46 Christian Brauner
  2026-08-12 21:46 ` [PATCH RFC v2 01/25] review-tui: fix rethreaded series thread viewing Christian Brauner
                   ` (24 more replies)
  0 siblings, 25 replies; 26+ messages in thread
From: Christian Brauner @ 2026-08-12 21:46 UTC (permalink / raw)
  To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Christian Brauner (Amutable)

The review TUI binds all mail visibility to the revision a series is
tracked at. 'e' opens only the tracked thread, unread badges exist only
on the series row, and u/U updates poll only the tracked revision.
Earlier versions of a series are invisible.

Make every known version of a tracked series a first-class citizen of the
mail-tracking machinery.

The revisions catalog becomes the only home for per-revision
read state (schema v11).

Per-change_id state moves the other way, onto a new `changes` table
(schema v12). There is one b4/review/<change_id> branch, so its sha
should not be stored once per version and read back with an ORDER BY.
The catalog-sync and the backward-search live there too.

u/U updates poll every cataloged revision for new mail using the same
thread queries used elsewhere. Neither lore nor b4 really support
incremental updates though. Lore misses that functionality afaict.

A new "Find older revisions" action runs the b4 am/mbox backward search.
It uses the change-id query when the cover carries one and subject+sender
otherwise. It records what it finds and polls the new entries
immediately. A retitled series without a change-id still needs manual
linking. The same limitation b4 am -vN has.

The tracker list gains expandable per-version rows. 'x' unfolds a
series into child rows. The tracked revision is starred. 'X' toggles all
series at once. Enter or 'e' on a child opens that version's thread with
revision-correct. 'd' range-diffs it against the tracked revision
directly.

Message-level read state was already keyed by message-id alone. So
Seen/Flagged/Answered flags apply across versions unchanged. This series
adds the count, badge and navigation layers on top.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
Changes in v2:
- Redesign the whole approach.

---
Christian Brauner (25):
      review-tui: fix rethreaded series thread viewing
      review: do not clear fields a re-adding caller does not know
      review-tui: keep the rethread flag on an upgraded series row
      review: guard the tracking-commit amend on the worktree, not the checkout
      review-tui: recompute an evicted A·R·T cache entry
      review: test the prerequisite fixes
      review: serialize schema migrations against a concurrent opener
      review: test the migration serialization
      review: track message counts for all revisions of a series
      review: give per-change_id state its own table
      review: test per-revision message tracking
      review-tui: poll every revision on u/U updates
      review: test the per-revision poll sweep
      review-tui: resolve the tracked revision in revision lists
      review-tui: fall back when a cached thread blob has no series
      review-tui: test revision resolution and the range-diff fallback
      review: skip the catalog mirror when nothing moved
      review: match a stray posting by message-id
      review: add backward discovery of older series revisions
      review-tui: add a "Find older revisions" action
      review: test the catalog mirror, stray matching and backward discovery
      review-tui: extract the Msgs column renderer from TrackedSeriesItem
      review-tui: give the unseen badge a column of its own
      review-tui: expand tracked series into per-version rows
      review-tui: test per-version tracker rows

 docs/maintainer/review.rst         |   49 +
 src/b4/__init__.py                 |   60 +
 src/b4/review/_review.py           |  284 +-
 src/b4/review/tracking.py          | 2378 ++++++++++++++--
 src/b4/review_tui/_common.py       |  177 +-
 src/b4/review_tui/_lite_app.py     |    1 +
 src/b4/review_tui/_modals.py       |   15 +
 src/b4/review_tui/_review_app.py   |    2 +-
 src/b4/review_tui/_tracking_app.py | 1083 ++++++--
 src/tests/conftest.py              |   23 +
 src/tests/test___init__.py         |   62 +
 src/tests/test_review.py           |  822 ++++++
 src/tests/test_review_tracking.py  | 5256 +++++++++++++++++++++++++++++++++++-
 src/tests/test_tui_modals.py       |    1 +
 src/tests/test_tui_review.py       |  274 +-
 src/tests/test_tui_tracking.py     | 1983 +++++++++++++-
 16 files changed, 11911 insertions(+), 559 deletions(-)
---
base-commit: 362b87aa4d036884c36e4bfb9cdbdee626aba3bf
change-id: 20260718-work-b4-multiver-rows-6032fb71e951


^ permalink raw reply	[flat|nested] 26+ messages in thread

* [PATCH RFC v2 01/25] review-tui: fix rethreaded series thread viewing
  2026-08-12 21:46 [PATCH RFC v2 00/25] review: track and browse every version of a tracked series Christian Brauner
@ 2026-08-12 21:46 ` Christian Brauner
  2026-08-12 21:46 ` [PATCH RFC v2 02/25] review: do not clear fields a re-adding caller does not know Christian Brauner
                   ` (23 subsequent siblings)
  24 siblings, 0 replies; 26+ messages in thread
From: Christian Brauner @ 2026-08-12 21:46 UTC (permalink / raw)
  To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Christian Brauner (Amutable)

The lite thread viewer does not put is_rethreaded into the series dict it
hands to retrieve_series_messages(), so pressing 'e' on a rethreaded
series fetches a single patch's thread instead of reassembling the series
from its per-patch message-ids.

Forward the flag through tracking_info.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 src/b4/review_tui/_lite_app.py     | 1 +
 src/b4/review_tui/_tracking_app.py | 1 +
 2 files changed, 2 insertions(+)

diff --git a/src/b4/review_tui/_lite_app.py b/src/b4/review_tui/_lite_app.py
index 51e14378..a75536d1 100644
--- a/src/b4/review_tui/_lite_app.py
+++ b/src/b4/review_tui/_lite_app.py
@@ -551,6 +551,7 @@ class LiteThreadScreen(ModalScreen[None]):
                 'message_id': self._message_id,
                 'change_id': ti.get('change_id', ''),
                 'revision': ti.get('revision'),
+                'is_rethreaded': bool(ti.get('is_rethreaded')),
             }
             identifier = ti.get('identifier', '')
             if identifier:
diff --git a/src/b4/review_tui/_tracking_app.py b/src/b4/review_tui/_tracking_app.py
index 5b7a7563..9f0a0302 100644
--- a/src/b4/review_tui/_tracking_app.py
+++ b/src/b4/review_tui/_tracking_app.py
@@ -1704,6 +1704,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
                 'identifier': self._identifier,
                 'change_id': self._selected_series.get('change_id', ''),
                 'revision': self._selected_series.get('revision', 1),
+                'is_rethreaded': bool(self._selected_series.get('is_rethreaded')),
             }
         self._focus_change_id = self._selected_series.get('change_id')
         from b4.review_tui._lite_app import LiteThreadScreen

-- 
2.53.0


^ permalink raw reply related	[flat|nested] 26+ messages in thread

* [PATCH RFC v2 02/25] review: do not clear fields a re-adding caller does not know
  2026-08-12 21:46 [PATCH RFC v2 00/25] review: track and browse every version of a tracked series Christian Brauner
  2026-08-12 21:46 ` [PATCH RFC v2 01/25] review-tui: fix rethreaded series thread viewing Christian Brauner
@ 2026-08-12 21:46 ` Christian Brauner
  2026-08-12 21:46 ` [PATCH RFC v2 03/25] review-tui: keep the rethread flag on an upgraded series row Christian Brauner
                   ` (22 subsequent siblings)
  24 siblings, 0 replies; 26+ messages in thread
From: Christian Brauner @ 2026-08-12 21:46 UTC (permalink / raw)
  To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Christian Brauner (Amutable)

add_series_to_db()'s UPSERT arm writes pw_series_id and fingerprint
straight from the excluded row, so every re-add clears whatever the
caller did not happen to know.

The callers do not all know both.  The Patchwork tracker re-adds a series
to attach its pw id and has no fingerprint.  rescan_branches() replays a
branch and has neither.  A CLI re-track has the fingerprint and no pw id.
Each one dropped what the others had recorded, leaving a series that no
longer matched by content, or one that lost its Patchwork link.

Passing None means "not known here", not "clear it".  Use COALESCE.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 src/b4/review/tracking.py | 14 +++++++++++---
 1 file changed, 11 insertions(+), 3 deletions(-)

diff --git a/src/b4/review/tracking.py b/src/b4/review/tracking.py
index a0c4ca4f..52b9a897 100644
--- a/src/b4/review/tracking.py
+++ b/src/b4/review/tracking.py
@@ -449,7 +449,15 @@ def add_series_to_db(
     added_at: Optional[str] = None,
     is_rethreaded: bool = False,
 ) -> int:
-    """Add a series to the tracking database. Returns the track_id."""
+    """Add a series to the tracking database. Returns the track_id.
+
+    On conflict the identity fields converge instead of overwriting: a
+    caller that does not know the Patchwork id or the fingerprint leaves
+    an existing one in place.  Re-adds come from callers that never
+    learned those fields -- the Patchwork tracker attaching its id,
+    rescan_branches replaying a branch -- and each used to wipe whatever
+    the others had recorded.
+    """
     if added_at is None:
         added_at = datetime.datetime.now(datetime.timezone.utc).isoformat()
     cursor = conn.execute(
@@ -466,8 +474,8 @@ def add_series_to_db(
             added_at = COALESCE(series.added_at, excluded.added_at),
             message_id = excluded.message_id,
             num_patches = excluded.num_patches,
-            pw_series_id = excluded.pw_series_id,
-            fingerprint = excluded.fingerprint,
+            pw_series_id = COALESCE(excluded.pw_series_id, series.pw_series_id),
+            fingerprint = COALESCE(excluded.fingerprint, series.fingerprint),
             is_rethreaded = excluded.is_rethreaded
         RETURNING track_id
     """,

-- 
2.53.0


^ permalink raw reply related	[flat|nested] 26+ messages in thread

* [PATCH RFC v2 03/25] review-tui: keep the rethread flag on an upgraded series row
  2026-08-12 21:46 [PATCH RFC v2 00/25] review: track and browse every version of a tracked series Christian Brauner
  2026-08-12 21:46 ` [PATCH RFC v2 01/25] review-tui: fix rethreaded series thread viewing Christian Brauner
  2026-08-12 21:46 ` [PATCH RFC v2 02/25] review: do not clear fields a re-adding caller does not know Christian Brauner
@ 2026-08-12 21:46 ` Christian Brauner
  2026-08-12 21:46 ` [PATCH RFC v2 04/25] review: guard the tracking-commit amend on the worktree, not the checkout Christian Brauner
                   ` (21 subsequent siblings)
  24 siblings, 0 replies; 26+ messages in thread
From: Christian Brauner @ 2026-08-12 21:46 UTC (permalink / raw)
  To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Christian Brauner (Amutable)

The upgrade path resolves whether the target revision needs rethreading
and threads target_is_rethreaded through five call sites, then does not
pass it to add_series_to_db().  The UPSERT arm writes is_rethreaded from
the excluded row, so the parameter's False default lands on the row and
clears an existing 1.

A series row saying 0 for a revision that really was stitched together
from individually fetched patches sends retrieve_series_messages() down
the single-msgid path: 'e' shows one patch's thread instead of the
series, and the message count collapses to match.

Pass the flag the upgrade already computed, and stop depending on every
caller remembering to.  The flag describes the posting, which the
revisions catalog already records, so resolve it from there on the way in
and keep the argument for a first track, before there is a catalog row to
ask.  Make the UPSERT arm sticky rather than overwriting, as
add_revision() already is.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 src/b4/review/tracking.py          | 20 +++++++++++++++++---
 src/b4/review_tui/_tracking_app.py |  6 ++++++
 2 files changed, 23 insertions(+), 3 deletions(-)

diff --git a/src/b4/review/tracking.py b/src/b4/review/tracking.py
index 52b9a897..710b4a0d 100644
--- a/src/b4/review/tracking.py
+++ b/src/b4/review/tracking.py
@@ -456,7 +456,16 @@ def add_series_to_db(
     an existing one in place.  Re-adds come from callers that never
     learned those fields -- the Patchwork tracker attaching its id,
     rescan_branches replaying a branch -- and each used to wipe whatever
-    the others had recorded.
+    the others had recorded.  ``is_rethreaded`` is sticky for the same
+    reason, matching the catalog's :func:`add_revision`.
+
+    ``is_rethreaded`` describes the posting, so the catalog's answer for
+    this revision wins over the argument on the way in.  The argument is
+    still what a first track supplies, before there is a catalog row to
+    ask.  Callers that hand-carry the flag from a catalog lookup are then
+    merely agreeing with the row rather than being the only thing standing
+    between it and a False default -- which is what made dropping it at
+    one call site quietly clear the column.
     """
     if added_at is None:
         added_at = datetime.datetime.now(datetime.timezone.utc).isoformat()
@@ -465,7 +474,9 @@ def add_series_to_db(
         INSERT INTO series
         (change_id, revision, subject, sender_name, sender_email, sent_at, added_at,
          message_id, num_patches, pw_series_id, fingerprint, is_rethreaded)
-        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
+                MAX(?, COALESCE((SELECT is_rethreaded FROM revisions
+                                 WHERE change_id = ? AND revision = ?), 0)))
         ON CONFLICT (change_id, revision) DO UPDATE SET
             subject = excluded.subject,
             sender_name = excluded.sender_name,
@@ -476,7 +487,8 @@ def add_series_to_db(
             num_patches = excluded.num_patches,
             pw_series_id = COALESCE(excluded.pw_series_id, series.pw_series_id),
             fingerprint = COALESCE(excluded.fingerprint, series.fingerprint),
-            is_rethreaded = excluded.is_rethreaded
+            is_rethreaded = MAX(COALESCE(series.is_rethreaded, 0),
+                                excluded.is_rethreaded)
         RETURNING track_id
     """,
         (
@@ -492,6 +504,8 @@ def add_series_to_db(
             pw_series_id,
             fingerprint,
             int(is_rethreaded),
+            change_id,
+            revision,
         ),
     )
     track_id = cursor.fetchone()[0]
diff --git a/src/b4/review_tui/_tracking_app.py b/src/b4/review_tui/_tracking_app.py
index 9f0a0302..69da8f58 100644
--- a/src/b4/review_tui/_tracking_app.py
+++ b/src/b4/review_tui/_tracking_app.py
@@ -4462,6 +4462,12 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
                         sent_at,
                         target_msgid,
                         lser.expected or num_am,
+                        # The upgrade already resolved this; without it the
+                        # UPSERT writes the column's 0 default (and clears an
+                        # existing 1), which sends every later retrieval of
+                        # the now-tracked revision down the single-msgid path
+                        # instead of reassembling it from its member patches.
+                        is_rethreaded=bool(target_is_rethreaded),
                     )
                     b4.review.tracking.update_series_status(
                         conn, change_id, 'reviewing', revision=target_rev

-- 
2.53.0


^ permalink raw reply related	[flat|nested] 26+ messages in thread

* [PATCH RFC v2 04/25] review: guard the tracking-commit amend on the worktree, not the checkout
  2026-08-12 21:46 [PATCH RFC v2 00/25] review: track and browse every version of a tracked series Christian Brauner
                   ` (2 preceding siblings ...)
  2026-08-12 21:46 ` [PATCH RFC v2 03/25] review-tui: keep the rethread flag on an upgraded series row Christian Brauner
@ 2026-08-12 21:46 ` Christian Brauner
  2026-08-12 21:46 ` [PATCH RFC v2 05/25] review-tui: recompute an evicted A·R·T cache entry Christian Brauner
                   ` (20 subsequent siblings)
  24 siblings, 0 replies; 26+ messages in thread
From: Christian Brauner @ 2026-08-12 21:46 UTC (permalink / raw)
  To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Christian Brauner (Amutable)

save_tracking_ref() rewrites a review branch's tip with commit-tree plus
update-ref.  Unlike `git branch -f`, that pair will move a branch out
from under a live worktree and strand an in-progress `git am` or rebase
on a commit that is no longer the branch tip.

"Checked out" is the wrong test for it.  The amend reuses the branch's
own tree, so a quiescent checkout survives one untouched: HEAD moves, the
working tree and index still match it, `git status` stays clean.
Refusing every checkout would refuse the review UI amending the tracking
commit on the branch it has just checked out itself, and would defer the
sweep's own writes for as long as a series stays under review.

What the amend cannot survive is an operation in flight.  A `git am`,
rebase, merge, cherry-pick, revert or bisect keeps state that names the
tip being replaced, and git records that per worktree.

Put the rule in the writer rather than in each caller, so the two writers
that reach update-ref through _store_thread_blob() and
ensure_thread_context_blob() are covered as well.  A lookup that fails
counts as busy, since this guards a ref move.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 src/b4/__init__.py        | 60 +++++++++++++++++++++++++++++++++++++++++++++++
 src/b4/review/_review.py  | 20 ++++++++++++++++
 src/b4/review/tracking.py |  7 ++++++
 3 files changed, 87 insertions(+)

diff --git a/src/b4/__init__.py b/src/b4/__init__.py
index a52b0011..169547c0 100644
--- a/src/b4/__init__.py
+++ b/src/b4/__init__.py
@@ -4601,6 +4601,66 @@ def git_branch_checked_out(gitdir: Optional[str], branch_name: str) -> bool:
     return False
 
 
+# What a git operation leaves in a worktree's own gitdir while it is in
+# flight.  Each of these names a sequencer or a state that remembers the
+# tip the operation started from.
+_WORKTREE_OP_STATE = (
+    'rebase-apply',  # git am, git rebase --apply
+    'rebase-merge',  # git rebase --merge / -i
+    'MERGE_HEAD',
+    'CHERRY_PICK_HEAD',
+    'REVERT_HEAD',
+    'BISECT_LOG',
+    'sequencer',
+)
+
+
+def git_worktree_busy(gitdir: Optional[str], branch_name: str) -> bool:
+    """Whether a git operation is in flight where *branch_name* is checked out.
+
+    The question anything rewriting a branch's tip commit in place has to
+    ask, and it is narrower than :func:`git_branch_checked_out`.  An amend
+    that reuses the branch's own tree moves HEAD and nothing else -- the
+    working tree and index still match it, and `git status` stays clean --
+    so a quiescent checkout survives one untouched.  Refusing every
+    checkout instead would refuse the review UI amending the tracking
+    commit on the branch it has just checked out itself, which is the
+    common and correct case.
+
+    What such an amend cannot survive is a `git am`, rebase, merge,
+    cherry-pick, revert or bisect in flight: those keep state naming the
+    tip they started from, and moving it out from under them strands the
+    operation.
+
+    Not knowing counts as busy.  This guards a ref move, so a lookup that
+    fails must not read as permission.
+    """
+    wantref = f'refs/heads/{branch_name.removeprefix("refs/heads/")}'
+    ecode, out = git_run_command(gitdir, ['worktree', 'list', '--porcelain'])
+    if ecode != 0:
+        logger.debug('Could not list worktrees, assuming %s is busy', branch_name)
+        return True
+    wtpath = None
+    current = None
+    for line in out.splitlines():
+        if line.startswith('worktree '):
+            current = line[9:].strip()
+        elif line.startswith('branch ') and line[7:].strip() == wantref:
+            wtpath = current
+            break
+    if not wtpath:
+        # Checked out nowhere, so there is no operation to strand.
+        return False
+    ecode, out = git_run_command(wtpath, ['rev-parse', '--absolute-git-dir'])
+    if ecode != 0:
+        logger.debug('Could not resolve the gitdir of %s, assuming busy', wtpath)
+        return True
+    wtgitdir = out.strip()
+    return any(
+        os.path.exists(os.path.join(wtgitdir, name)) for name in _WORKTREE_OP_STATE
+    )
+
+
 def git_revparse_tag(gitdir: Optional[str], tagname: str) -> Optional[str]:
     if not tagname.startswith('refs/tags/'):
         fulltag = f'refs/tags/{tagname}'
diff --git a/src/b4/review/_review.py b/src/b4/review/_review.py
index 5fa4d2ab..98f51363 100644
--- a/src/b4/review/_review.py
+++ b/src/b4/review/_review.py
@@ -721,12 +721,32 @@ def save_tracking_ref(
     Uses git commit-tree + git update-ref so that commit.gpgsign and
     hooks are not triggered — tracking commits are ephemeral and do
     not benefit from signing.  Returns True on success.
+
+    Declines while the worktree holding *branch* has a git operation in
+    flight.  update-ref, unlike `git branch -f`, will happily move a
+    branch out from under a live worktree, and a `git am` or rebase keeps
+    state naming the tip this is about to replace.  The rule lives here
+    rather than in each caller because it is a property of the write:
+    every caller that moves this ref has to respect it, and the two that
+    reached update-ref through :func:`b4.review.tracking._store_thread_blob`
+    and :func:`b4.review.tracking.ensure_thread_context_blob` did not.
+
+    The tree is the branch's own, so a *quiescent* checkout is not a
+    reason to decline -- see :func:`b4.git_worktree_busy`.
     """
     if not branch.startswith(REVIEW_BRANCH_PREFIX):
         logger.critical(
             'Refusing to write tracking commit to non-review branch: %s', branch
         )
         return False
+    if b4.git_worktree_busy(topdir, branch):
+        # Said out loud, not at debug: :func:`save_tracking` turns a False
+        # into `Unable to amend tracking commit` and exits, and a maintainer
+        # who has a rebase in flight on the branch is owed the reason.  The
+        # sweep is the one caller that meets this routinely, and it runs
+        # inside _quiet_cron/_quiet_worker.
+        logger.info('%s is mid-operation, not amending its tracking commit', branch)
+        return False
     commit_msg = cover_text + '\n\n' + make_review_magic_json(tracking)
     ecode, out = b4.git_run_command(topdir, ['rev-parse', f'{branch}^{{tree}}'])
     if ecode > 0:
diff --git a/src/b4/review/tracking.py b/src/b4/review/tracking.py
index 710b4a0d..2722e6c3 100644
--- a/src/b4/review/tracking.py
+++ b/src/b4/review/tracking.py
@@ -1223,6 +1223,13 @@ def sync_revisions_catalog_to_branch(
     that cannot be re-derived from lore — travels with the branch on push.
     No-op (returns False) when there is no topdir, no such branch, or the
     catalog is already current.
+
+    A branch whose worktree is mid-operation is declined by
+    :func:`b4.review.save_tracking_ref` itself, so there is no test for it
+    here: the rule belongs to the write, and repeating it per caller is
+    how it came to be enforced for this one and not for the other two.
+    The catalog is mirrored again on the next sweep that finds the
+    worktree free.
     """
     if not topdir:
         return False

-- 
2.53.0


^ permalink raw reply related	[flat|nested] 26+ messages in thread

* [PATCH RFC v2 05/25] review-tui: recompute an evicted A·R·T cache entry
  2026-08-12 21:46 [PATCH RFC v2 00/25] review: track and browse every version of a tracked series Christian Brauner
                   ` (3 preceding siblings ...)
  2026-08-12 21:46 ` [PATCH RFC v2 04/25] review: guard the tracking-commit amend on the worktree, not the checkout Christian Brauner
@ 2026-08-12 21:46 ` Christian Brauner
  2026-08-12 21:46 ` [PATCH RFC v2 06/25] review: test the prerequisite fixes Christian Brauner
                   ` (19 subsequent siblings)
  24 siblings, 0 replies; 26+ messages in thread
From: Christian Brauner @ 2026-08-12 21:46 UTC (permalink / raw)
  To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Christian Brauner (Amutable)

_invalidate_caches(change_id) evicts a single series from the A·R·T count
cache, but _load_series() only refills that cache when the whole dict is
None, so the evicted entry is never recomputed.  The series dicts are
rebuilt from scratch on every load, so nothing carries the old value
forward either, and the series renders '-' in the A·R·T column for the
rest of the session.

Take, link, upgrade, snooze, unsnooze, waiting and thank all evict
exactly one entry, so every one of them hits this.

Refill whenever a wanted branch is missing from the cache rather than
only when the cache is gone, and merge rather than replace so an intact
cache still skips the subprocess.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 src/b4/review_tui/_tracking_app.py | 34 +++++++++++++++++++++++++++++++---
 1 file changed, 31 insertions(+), 3 deletions(-)

diff --git a/src/b4/review_tui/_tracking_app.py b/src/b4/review_tui/_tracking_app.py
index 69da8f58..11e79ef2 100644
--- a/src/b4/review_tui/_tracking_app.py
+++ b/src/b4/review_tui/_tracking_app.py
@@ -988,7 +988,11 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         self._cached_newest_revisions: Optional[Dict[str, int]] = None
         self._cached_revision_counts: Optional[Dict[str, int]] = None
         self._cached_revisions: Optional[Dict[str, List[Dict[str, Any]]]] = None
-        self._cached_art_counts: Optional[Dict[str, Tuple[int, int, int]]] = None
+        # A None value is a branch whose tip carries no tracking trailer
+        # block; cached as a miss so the refill below converges.
+        self._cached_art_counts: Optional[Dict[str, Optional[Tuple[int, int, int]]]] = (
+            None
+        )
 
     def _invalidate_caches(self, change_id: Optional[str] = None) -> None:
         """Drop cached data so the next _load_series re-fetches.
@@ -1190,8 +1194,32 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
                     art_branches[branch_name] = branch_tips[branch_name]
 
         # --- Bulk ART counts (1 subprocess instead of N) ---
-        if self._cached_art_counts is None and art_branches and topdir:
-            self._cached_art_counts = _get_art_counts_batch(topdir, art_branches)
+        # Recompute whenever a wanted branch is missing, not only when the
+        # whole dict is gone: _invalidate_caches(change_id) evicts a single
+        # entry, which an `is None` test would never notice, leaving that
+        # series' A·R·T stuck at '-' for the session.
+        #
+        # Only the missing ones, though.  The targeted eviction keeps the
+        # other entries precisely so they are not recomputed, and handing
+        # the whole map to the batch spends that back: one tracking commit
+        # read per branch under review, on every take, link, snooze or
+        # thank, to re-derive counts nothing has invalidated.
+        art_missing = {
+            name: sha
+            for name, sha in art_branches.items()
+            if not self._cached_art_counts or name not in self._cached_art_counts
+        }
+        if topdir and art_missing:
+            counts = _get_art_counts_batch(topdir, art_missing)
+            if self._cached_art_counts is None:
+                self._cached_art_counts = {}
+            self._cached_art_counts.update(counts)
+            # Misses recorded too, or a branch _get_art_counts_batch declines
+            # to return stays absent, the difference above never empties and
+            # a `git cat-file` fires on every reload -- once a second while a
+            # cron sweep bumps the DB mtime.
+            for branch_name in art_missing:
+                self._cached_art_counts.setdefault(branch_name, None)
         art_map = self._cached_art_counts or {}
         for series in self._all_series:
             change_id = series.get('change_id', '')

-- 
2.53.0


^ permalink raw reply related	[flat|nested] 26+ messages in thread

* [PATCH RFC v2 06/25] review: test the prerequisite fixes
  2026-08-12 21:46 [PATCH RFC v2 00/25] review: track and browse every version of a tracked series Christian Brauner
                   ` (4 preceding siblings ...)
  2026-08-12 21:46 ` [PATCH RFC v2 05/25] review-tui: recompute an evicted A·R·T cache entry Christian Brauner
@ 2026-08-12 21:46 ` Christian Brauner
  2026-08-12 21:46 ` [PATCH RFC v2 07/25] review: serialize schema migrations against a concurrent opener Christian Brauner
                   ` (18 subsequent siblings)
  24 siblings, 0 replies; 26+ messages in thread
From: Christian Brauner @ 2026-08-12 21:46 UTC (permalink / raw)
  To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Christian Brauner (Amutable)

Cover the four fixes the rest of the series builds on: the rethread flag
reaching retrieve_series_messages() through tracking_info and the thread
viewer's series dict, the UPSERT convergence of is_rethreaded plus the
Patchwork id and fingerprint a re-adding caller does not know, the
catalog mirror declining a branch whose worktree is mid-operation, and
the A·R·T cache refilling an entry a targeted invalidation evicted.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 src/tests/conftest.py             |  23 ++++
 src/tests/test___init__.py        |  62 ++++++++++
 src/tests/test_review_tracking.py | 172 ++++++++++++++++++++++++++++
 src/tests/test_tui_tracking.py    | 235 ++++++++++++++++++++++++++++++++++++++
 4 files changed, 492 insertions(+)

diff --git a/src/tests/conftest.py b/src/tests/conftest.py
index aa6dbc84..a880e9af 100644
--- a/src/tests/conftest.py
+++ b/src/tests/conftest.py
@@ -44,6 +44,29 @@ def settestdefaults(
     monkeypatch.setattr(sys, '_running_in_pytest', True, raising=False)
 
 
+@pytest.fixture(scope='function', autouse=True)
+def clear_lore_cancel() -> Generator[None, None, None]:
+    """Clear liblore's cancel flag between tests.
+
+    The flag is process-global and sticky by design, and a TrackingApp sets
+    it on shutdown (LoreNodeShutdownMixin cancels the node so an in-flight
+    fetch stops).  Left set, it makes the next test that reaches lore raise
+    OperationCancelledError -- and only the TUI resets it, inside
+    lore_request(), so the plain sweep path in b4.review has no reset point
+    at all.
+
+    That turns a suite into an order-dependent one: any TUI test poisons a
+    later non-TUI one, and today only alphabetical file order hides it.  A
+    reorder -- pytest-randomly, --lf, a subset, CI sharding -- surfaces it,
+    pointing at the wrong file entirely.
+    """
+    yield
+    try:
+        b4.get_lore_node().reset_cancel()
+    except Exception:
+        pass
+
+
 @pytest.fixture(scope='function')
 def sampledir(request: pytest.FixtureRequest) -> str:
     return os.path.join(request.path.parent, 'samples')
diff --git a/src/tests/test___init__.py b/src/tests/test___init__.py
index f45aea56..cfc5882c 100644
--- a/src/tests/test___init__.py
+++ b/src/tests/test___init__.py
@@ -1236,6 +1236,68 @@ def test_git_run_command_log_fixup_looks_past_option_prefix(gitdir: str) -> None
     assert len(sha) == 40, f'log abbreviated the sha despite the fixup: {sha}'
 
 
+class TestGitWorktreeBusy:
+    """Tests for git_worktree_busy().
+
+    Deliberately narrower than git_branch_checked_out(): amending a tip
+    commit in place reuses the branch's own tree, so a quiescent checkout
+    survives it and only an operation in flight does not.
+    """
+
+    def test_a_quiescent_checkout_is_not_busy(self, gitdir: str) -> None:
+        """The distinction the whole predicate exists to draw."""
+        ecode, out = b4.git_run_command(gitdir, ['branch', '--show-current'])
+        assert ecode == 0
+        current = out.strip()
+        assert b4.git_branch_checked_out(gitdir, current) is True
+        assert b4.git_worktree_busy(gitdir, current) is False
+
+    def test_an_operation_in_flight_is_busy(self, gitdir: str) -> None:
+        ecode, out = b4.git_run_command(gitdir, ['branch', '--show-current'])
+        assert ecode == 0
+        current = out.strip()
+        os.makedirs(os.path.join(gitdir, '.git', 'rebase-apply'), exist_ok=True)
+        assert b4.git_worktree_busy(gitdir, current) is True
+
+    def test_a_branch_nobody_has_checked_out_is_not_busy(self, gitdir: str) -> None:
+        """No worktree, so there is no operation to strand."""
+        ecode, _ = b4.git_run_command(gitdir, ['branch', 'parked-branch'])
+        assert ecode == 0
+        assert b4.git_worktree_busy(gitdir, 'parked-branch') is False
+        assert b4.git_worktree_busy(gitdir, 'no-such-branch') is False
+
+    def test_a_linked_worktree_is_looked_at_on_its_own(
+        self, gitdir: str, tmp_path: pathlib.Path
+    ) -> None:
+        """State lives in the worktree's own gitdir, not the common one."""
+        wtpath = str(tmp_path / 'busy-wt')
+        ecode, out = b4.git_run_command(
+            gitdir, ['worktree', 'add', '-b', 'wt-busy', wtpath], logstderr=True
+        )
+        assert ecode == 0, out
+        try:
+            assert b4.git_worktree_busy(gitdir, 'wt-busy') is False
+            ecode, wtgit = b4.git_run_command(
+                wtpath, ['rev-parse', '--absolute-git-dir']
+            )
+            assert ecode == 0
+            os.makedirs(os.path.join(wtgit.strip(), 'rebase-merge'), exist_ok=True)
+            assert b4.git_worktree_busy(gitdir, 'wt-busy') is True
+            assert b4.git_worktree_busy(gitdir, 'refs/heads/wt-busy') is True
+            # The main worktree is untouched by the other one's state.
+            ecode, out = b4.git_run_command(gitdir, ['branch', '--show-current'])
+            assert b4.git_worktree_busy(gitdir, out.strip()) is False
+        finally:
+            b4.git_run_command(gitdir, ['worktree', 'remove', '--force', wtpath])
+
+    def test_a_lookup_that_fails_counts_as_busy(
+        self, gitdir: str, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """This guards a ref move, so not knowing must not read as consent."""
+        monkeypatch.setattr(b4, 'git_run_command', lambda *a, **kw: (1, ''))
+        assert b4.git_worktree_busy(gitdir, 'anything') is True
+
+
 class TestGitBranchCheckedOut:
     """Tests for git_branch_checked_out()."""
 
diff --git a/src/tests/test_review_tracking.py b/src/tests/test_review_tracking.py
index 64778be3..4cde7cdd 100644
--- a/src/tests/test_review_tracking.py
+++ b/src/tests/test_review_tracking.py
@@ -4359,6 +4359,31 @@ class TestSyncRevisionsCatalogToBranch:
             is False
         )
 
+    def test_sync_leaves_a_busy_worktree_alone(
+        self, gitdir: str, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """save_tracking_ref moves the ref with update-ref.
+
+        Unlike `git branch -f` that succeeds under a live worktree, which
+        strands an in-progress am or rebase.  The catalog is mirrored again
+        on the next pass that finds the branch free.
+        """
+        identifier = 'rt-port-sync-checkedout'
+        _make_review_branch_with_catalog(gitdir, identifier, 'cid-A', 5, [])
+        conn = review_tracking.init_db(identifier)
+        review_tracking.add_revision(conn, 'cid-A', 5, 'v5@example.com')
+        review_tracking.add_revision(conn, 'cid-A', 6, 'v6@example.com')
+        conn.close()
+        monkeypatch.setattr(b4, 'git_worktree_busy', lambda topdir, branch: True)
+        assert (
+            review_tracking.sync_revisions_catalog_to_branch(
+                gitdir, identifier, 'cid-A'
+            )
+            is False
+        )
+        _cover, tracking = b4.review.load_tracking(gitdir, 'b4/review/cid-A')
+        assert tracking.get('known-revisions', []) == []
+
 
 class TestKnownProjects:
     """Tests for the identifier→repository reverse mapping."""
@@ -4864,3 +4889,150 @@ class TestAutoWakeSkipsCheckedOutBranch:
         woken = review_tracking.auto_wake_snoozed(identifier, gitdir)
         assert woken == 1
         assert self._status(identifier, change_id) == 'replied'
+
+
+class TestUpgradeKeepsTheRethreadFlag:
+    def test_add_series_to_db_upsert_keeps_the_flag_without_the_argument(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """The UPSERT's is_rethreaded is sticky, like the catalog's.
+
+        Not every re-adding caller knows the flag -- the Patchwork tracker
+        attaching a pw id, rescan_branches replaying a branch -- and a bare
+        `excluded.is_rethreaded` wrote each one's False default over an
+        existing 1, sending later retrievals down the single-msgid path.
+        """
+        conn = review_tracking.init_db('upgrade-rt')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=3,
+            subject='[PATCH v3] thing',
+            sender_name='S',
+            sender_email='s@e.com',
+            sent_at='2026-01-01T00:00:00+00:00',
+            message_id='v3@x',
+            num_patches=2,
+            is_rethreaded=True,
+        )
+        conn.commit()
+        # Re-add without the flag, the way _finish_tracking does.
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=3,
+            subject='[PATCH v3] thing',
+            sender_name='S',
+            sender_email='s@e.com',
+            sent_at='2026-01-01T00:00:00+00:00',
+            message_id='v3@x',
+            num_patches=2,
+        )
+        conn.commit()
+        row = conn.execute(
+            "SELECT is_rethreaded FROM series WHERE change_id = 'cid'"
+        ).fetchone()
+        conn.close()
+        assert row[0] == 1
+
+    def test_add_series_to_db_upsert_keeps_linkage_a_caller_lacks(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """fingerprint and pw_series_id survive a caller that has neither.
+
+        The same convergence rule as the flag: None means "not known
+        here", not "clear it" -- a CLI re-track must not detach the
+        Patchwork id, and a pw-side track must not null the fingerprint
+        the rethread machinery matches by.
+        """
+        conn = review_tracking.init_db('upgrade-linkage')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=3,
+            subject='[PATCH v3] thing',
+            sender_name='S',
+            sender_email='s@e.com',
+            sent_at='2026-01-01T00:00:00+00:00',
+            message_id='v3@x',
+            num_patches=2,
+            fingerprint='fp-abc',
+        )
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=3,
+            subject='[PATCH v3] thing',
+            sender_name='S',
+            sender_email='s@e.com',
+            sent_at='2026-01-01T00:00:00+00:00',
+            message_id='v3@x',
+            num_patches=2,
+            pw_series_id=77,
+        )
+        row = conn.execute(
+            "SELECT fingerprint, pw_series_id FROM series WHERE change_id = 'cid'"
+        ).fetchone()
+        conn.close()
+        assert (row[0], row[1]) == ('fp-abc', 77)
+
+    def test_the_catalog_answer_wins_over_a_forgotten_argument(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """is_rethreaded describes the posting, and the catalog owns that.
+
+        The upgrade path resolved the flag and then did not pass it on, so
+        the column's False default landed on the row.  Threading it through
+        fixes that call site; sourcing it from the catalog means no call
+        site can reintroduce the bug.
+        """
+        conn = review_tracking.init_db('rt-catalog-wins')
+        review_tracking.add_revision(conn, 'c', 2, 'v2@x', is_rethreaded=True)
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='c',
+            revision=2,
+            subject='[PATCH v2] thing',
+            sender_name='S',
+            sender_email='s@e.com',
+            sent_at='2026-01-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=1,
+            # deliberately not passed, as the upgrade path used to do
+        )
+        row = conn.execute(
+            "SELECT is_rethreaded FROM series WHERE change_id = 'c'"
+        ).fetchone()
+        conn.close()
+        assert row[0] == 1
+
+    def test_the_flag_does_not_leak_to_another_revision(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """Stickiness is per posting, not per series.
+
+        A series rethreaded at v2 and posted properly at v3 must record v3
+        as plain, or retrieval reassembles a series that was never split.
+        """
+        conn = review_tracking.init_db('rt-no-leak')
+        review_tracking.add_revision(conn, 'c', 2, 'v2@x', is_rethreaded=True)
+        review_tracking.add_revision(conn, 'c', 3, 'v3@x')
+        for rev in (2, 3):
+            review_tracking.add_series_to_db(
+                conn,
+                change_id='c',
+                revision=rev,
+                subject=f'[PATCH v{rev}] thing',
+                sender_name='S',
+                sender_email='s@e.com',
+                sent_at='2026-01-01T00:00:00+00:00',
+                message_id=f'v{rev}@x',
+                num_patches=1,
+            )
+        rows = dict(
+            conn.execute(
+                "SELECT revision, is_rethreaded FROM series WHERE change_id = 'c'"
+            ).fetchall()
+        )
+        conn.close()
+        assert rows == {2: 1, 3: 0}
diff --git a/src/tests/test_tui_tracking.py b/src/tests/test_tui_tracking.py
index 5ee2a242..664956ec 100644
--- a/src/tests/test_tui_tracking.py
+++ b/src/tests/test_tui_tracking.py
@@ -29,6 +29,7 @@ import b4
 import b4.review
 import b4.review.tracking as tracking
 import b4.review_tui._entry as _entry
+import b4.review_tui._tracking_app as _tracking_app
 from b4 import (
     _abort_worktree_op,
     _worktree_has_unmerged,
@@ -5762,3 +5763,237 @@ class TestTrackingEntryBranchRestore:
             _entry.run_tracking_tui('test-entry-dbclose')
 
         assert closed == [True]
+
+
+class TestArtCacheTargetedEviction:
+    @staticmethod
+    def _seed(identifier: str) -> None:
+        """Seed one reviewing series with a review branch to count A·R·T for.
+
+        Self-contained rather than reusing the version-row seeder: this
+        commit predates it, and a test that reaches forward for a helper
+        breaks every commit in between.
+        """
+        conn = tracking.init_db(identifier)
+        tracking.add_series_to_db(
+            conn,
+            change_id='multi-1',
+            revision=2,
+            subject='[PATCH v2 0/2] multi: test series',
+            sender_name='Vera Version',
+            sender_email='vera@example.com',
+            sent_at='2026-03-10T10:00:00+00:00',
+            message_id='multi-1-v2@example.com',
+            num_patches=2,
+        )
+        tracking.update_series_status(conn, 'multi-1', 'reviewing', revision=2)
+        conn.close()
+
+    @pytest.mark.asyncio
+    async def test_an_evicted_entry_is_recomputed(
+        self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """_invalidate_caches(change_id) drops one entry, not the dict.
+
+        _load_series used to refill only when the whole dict was None, so an
+        evicted series' A·R·T stayed '-' for the rest of the session.
+        """
+        self._seed('art-evict')
+
+        batches: List[Dict[str, str]] = []
+
+        def _fake_batch(topdir: str, branches: Dict[str, str]) -> Dict[str, Any]:
+            batches.append(dict(branches))
+            return {name: (1, 2, 3) for name in branches}
+
+        monkeypatch.setattr(
+            _tracking_app, '_get_art_counts_batch', _fake_batch, raising=True
+        )
+        monkeypatch.setattr(
+            _tracking_app,
+            '_get_review_branch_tips',
+            lambda topdir: {'b4/review/multi-1': 'deadbeef'},
+        )
+        # There is no branch on disk, and the startup rescan would mark the
+        # series 'gone' -- which contributes no ART branch at all.
+        monkeypatch.setattr(
+            tracking, 'rescan_branches', lambda identifier, topdir: {'gone': 0}
+        )
+
+        app = TrackingApp('art-evict')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            assert app._all_series[0].get('art') == (1, 2, 3)
+            assert len(batches) == 1
+
+            app._invalidate_caches('multi-1')
+            app._load_series()
+            await pilot.pause()
+            assert len(batches) == 2
+            assert app._all_series[0].get('art') == (1, 2, 3)
+
+    @pytest.mark.asyncio
+    async def test_only_the_evicted_branch_is_recomputed(
+        self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Keeping the other entries is the whole point of the targeted form.
+
+        Refilling by handing the batch every branch spends that back: one
+        tracking-commit read per series under review on every take, link,
+        snooze or thank, to re-derive counts nothing invalidated.
+        """
+        self._seed('art-scope')
+        conn = tracking.get_db('art-scope')
+        tracking.add_series_to_db(
+            conn,
+            change_id='multi-2',
+            revision=1,
+            subject='[PATCH 0/1] other: series',
+            sender_name='Otto Other',
+            sender_email='otto@example.com',
+            sent_at='2026-03-11T10:00:00+00:00',
+            message_id='multi-2-v1@example.com',
+            num_patches=1,
+        )
+        tracking.update_series_status(conn, 'multi-2', 'reviewing', revision=1)
+        conn.close()
+
+        batches: List[Dict[str, str]] = []
+
+        def _fake_batch(topdir: str, branches: Dict[str, str]) -> Dict[str, Any]:
+            batches.append(dict(branches))
+            return {name: (1, 2, 3) for name in branches}
+
+        monkeypatch.setattr(_tracking_app, '_get_art_counts_batch', _fake_batch)
+        monkeypatch.setattr(
+            _tracking_app,
+            '_get_review_branch_tips',
+            lambda topdir: {
+                'b4/review/multi-1': 'deadbeef',
+                'b4/review/multi-2': 'cafebabe',
+            },
+        )
+        monkeypatch.setattr(
+            tracking, 'rescan_branches', lambda identifier, topdir: {'gone': 0}
+        )
+
+        app = TrackingApp('art-scope')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            assert set(batches[0]) == {'b4/review/multi-1', 'b4/review/multi-2'}
+
+            app._invalidate_caches('multi-1')
+            app._load_series()
+            await pilot.pause()
+            assert len(batches) == 2
+            assert set(batches[1]) == {'b4/review/multi-1'}
+
+    @pytest.mark.asyncio
+    async def test_an_intact_cache_is_not_recomputed(
+        self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """The batch is a subprocess; a full cache must still short-circuit."""
+        self._seed('art-intact')
+
+        calls: List[int] = []
+
+        def _counting_batch(topdir: str, branches: Dict[str, str]) -> Dict[str, Any]:
+            calls.append(1)
+            return {name: (1, 2, 3) for name in branches}
+
+        monkeypatch.setattr(_tracking_app, '_get_art_counts_batch', _counting_batch)
+        monkeypatch.setattr(
+            _tracking_app,
+            '_get_review_branch_tips',
+            lambda topdir: {'b4/review/multi-1': 'deadbeef'},
+        )
+        monkeypatch.setattr(
+            tracking, 'rescan_branches', lambda identifier, topdir: {'gone': 0}
+        )
+
+        app = TrackingApp('art-intact')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            assert len(calls) == 1
+            app._load_series()
+            await pilot.pause()
+            assert len(calls) == 1
+
+
+class TestRethreadFlagReachesTheThreadFetch:
+    """The rethread flag has to survive the whole hop to retrieve_series_messages.
+
+    A rethreaded revision's recorded message-id is one patch's, so the
+    series is reassembled from its member patches instead.  The flag that
+    selects that path is carried by hand through the tracking list's
+    tracking_info dict and the thread viewer's series dict, and dropping it
+    at either hop fetches a single patch's thread instead of the series --
+    silently, with the message count collapsing to match.
+    """
+
+    @staticmethod
+    def _seed_rethreaded(identifier: str) -> None:
+        conn = tracking.init_db(identifier)
+        tracking.add_series_to_db(
+            conn,
+            change_id='rt-1',
+            revision=2,
+            subject='[PATCH v2 0/2] rt: stitched series',
+            sender_name='Rhea Rethread',
+            sender_email='rhea@example.com',
+            sent_at='2026-03-10T10:00:00+00:00',
+            message_id='rt-1-v2-p1@example.com',
+            num_patches=2,
+            is_rethreaded=True,
+        )
+        for rev in (1, 2):
+            tracking.add_revision(
+                conn,
+                'rt-1',
+                rev,
+                f'rt-1-v{rev}-p1@example.com',
+                subject=f'[PATCH v{rev} 1/2] rt: first',
+                is_rethreaded=rev == 2,
+            )
+        conn.close()
+
+    @pytest.mark.asyncio
+    async def test_tracking_info_carries_the_flag(self, tmp_path: pathlib.Path) -> None:
+        """[e] on a rethreaded series hands the viewer is_rethreaded=True."""
+        self._seed_rethreaded('rt-info')
+
+        app = TrackingApp('rt-info')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            with patch.object(app, 'push_screen') as mock_push:
+                await pilot.press('e')
+                await pilot.pause()
+            screen = mock_push.call_args[0][0]
+            assert screen._tracking_info['revision'] == 2
+            assert screen._tracking_info['is_rethreaded'] is True
+
+    def test_viewer_forwards_the_flag_to_the_retrieval(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """The viewer's series dict is what selects the reassembly path."""
+        from b4.review_tui._lite_app import LiteThreadScreen
+
+        seen: Dict[str, Any] = {}
+
+        def _capture(series: Dict[str, Any], identifier: str) -> List[Any]:
+            seen.update(series)
+            return []
+
+        screen = LiteThreadScreen(
+            'rt-1-v2-p1@example.com',
+            tracking_info={
+                'identifier': 'rt-fwd',
+                'change_id': 'rt-1',
+                'revision': 2,
+                'is_rethreaded': True,
+            },
+        )
+        with patch.object(b4.review, 'retrieve_series_messages', _capture):
+            screen._fetch_thread()
+        assert seen['is_rethreaded'] is True
+        assert seen['revision'] == 2

-- 
2.53.0


^ permalink raw reply related	[flat|nested] 26+ messages in thread

* [PATCH RFC v2 07/25] review: serialize schema migrations against a concurrent opener
  2026-08-12 21:46 [PATCH RFC v2 00/25] review: track and browse every version of a tracked series Christian Brauner
                   ` (5 preceding siblings ...)
  2026-08-12 21:46 ` [PATCH RFC v2 06/25] review: test the prerequisite fixes Christian Brauner
@ 2026-08-12 21:46 ` Christian Brauner
  2026-08-12 21:46 ` [PATCH RFC v2 08/25] review: test the migration serialization Christian Brauner
                   ` (17 subsequent siblings)
  24 siblings, 0 replies; 26+ messages in thread
From: Christian Brauner @ 2026-08-12 21:46 UTC (permalink / raw)
  To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Christian Brauner (Amutable)

_migrate_db_if_needed() reads the schema version and then issues DDL in
autocommit, so two processes opening the same database both decide to
migrate.  The loser dies on `duplicate column name: message_count`, or,
having read the version before the winner's DROP COLUMN landed, on
`no such column: message_count`.  Reproduced 6/6 with two threads
opening one v10 database.

busy_timeout, which _configure_conn sets for exactly this pair of
writers, does not help: neither side ever asks for a lock.

The TUI and a `b4 review cron` sweep are that pair.  The race has been
latent since the v8 migrations because nothing has needed migrating
since; the next schema bump makes it fire, once, on the first launch
after an upgrade, for every maintainer with the timer installed.  It does
not corrupt anything, since the loser's work rolls back and the winner
completes, but it surfaces as a raw sqlite traceback out of get_db(), and
update_revision_message_counts() catches only FileNotFoundError.

Take the write lock before re-reading the version, so the second process
finds the work already done.  sqlite's DDL is transactional, so this also
makes the migration atomic: the version bump can no longer commit
separately from the schema it describes, and an interrupted migration
rolls back whole rather than leaving a half-migrated database stamped
with the old version.

The version is still read once without the lock first.  The answer is "no
migration pending" on every open but the one after an upgrade, and that
path must not serialize every connection behind a write lock.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 src/b4/review/tracking.py | 44 ++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 42 insertions(+), 2 deletions(-)

diff --git a/src/b4/review/tracking.py b/src/b4/review/tracking.py
index 2722e6c3..cecfdb9c 100644
--- a/src/b4/review/tracking.py
+++ b/src/b4/review/tracking.py
@@ -135,10 +135,45 @@ def init_db(identifier: str) -> sqlite3.Connection:
 
 
 def _migrate_db_if_needed(conn: sqlite3.Connection) -> None:
-    """Apply any pending schema migrations in-place."""
+    """Apply any pending schema migrations in-place.
+
+    Serialized against other processes, because the TUI and a
+    ``b4 review cron`` sweep open the same database and a pending
+    migration is exactly what both of them find on the first run after an
+    upgrade.  Reading the version and then issuing DDL in autocommit let
+    both decide to migrate: the loser died on `duplicate column name`, or
+    -- having read the version before the winner's DROP landed -- on
+    `no such column`.  busy_timeout cannot help, since neither side ever
+    asked for a lock.
+
+    BEGIN IMMEDIATE takes the write lock before the version is re-read, so
+    the second process finds the work already done.  It also makes the
+    whole migration one transaction -- sqlite's DDL is transactional -- so
+    the version bump can no longer commit separately from the schema it
+    describes, and an interrupted migration rolls back whole.
+
+    The version is read once without the lock first: the answer is "no" on
+    every open but the one after an upgrade, and that path must not
+    serialize every connection behind a write lock.
+    """
+    row = conn.execute('SELECT version FROM schema_version').fetchone()
+    if row is not None and row[0] >= SCHEMA_VERSION:
+        return
+    conn.execute('BEGIN IMMEDIATE')
+    try:
+        _run_migrations(conn)
+    except Exception:
+        conn.rollback()
+        raise
+
+
+def _run_migrations(conn: sqlite3.Connection) -> None:
+    """The migration ladder, under the write lock :func:`_migrate_db_if_needed` took."""
     row = conn.execute('SELECT version FROM schema_version').fetchone()
     version = row[0] if row else 0
     if version >= SCHEMA_VERSION:
+        # Another process migrated while we waited for the lock.
+        conn.rollback()
         return
     if version < 2:
         conn.execute('ALTER TABLE series ADD COLUMN branch_sha TEXT')
@@ -198,7 +233,12 @@ def _migrate_db_if_needed(conn: sqlite3.Connection) -> None:
             conn.execute(
                 'ALTER TABLE revisions ADD COLUMN is_rethreaded INTEGER DEFAULT 0'
             )
-    conn.execute('UPDATE schema_version SET version = ?', (SCHEMA_VERSION,))
+    # Not an UPDATE: `version` is the primary key, so an UPDATE writes
+    # nothing at all against an empty table -- and the read above maps "no
+    # row" to version 0, so such a database would re-run the whole ladder
+    # on every open and never record that it had finished.
+    conn.execute('DELETE FROM schema_version')
+    conn.execute('INSERT INTO schema_version (version) VALUES (?)', (SCHEMA_VERSION,))
     conn.commit()
 
 

-- 
2.53.0


^ permalink raw reply related	[flat|nested] 26+ messages in thread

* [PATCH RFC v2 08/25] review: test the migration serialization
  2026-08-12 21:46 [PATCH RFC v2 00/25] review: track and browse every version of a tracked series Christian Brauner
                   ` (6 preceding siblings ...)
  2026-08-12 21:46 ` [PATCH RFC v2 07/25] review: serialize schema migrations against a concurrent opener Christian Brauner
@ 2026-08-12 21:46 ` Christian Brauner
  2026-08-12 21:46 ` [PATCH RFC v2 09/25] review: track message counts for all revisions of a series Christian Brauner
                   ` (16 subsequent siblings)
  24 siblings, 0 replies; 26+ messages in thread
From: Christian Brauner @ 2026-08-12 21:46 UTC (permalink / raw)
  To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Christian Brauner (Amutable)

Cover the three claims: two processes opening one database both come away
with a usable connection and the ladder having run once, a migration
interrupted partway rolls back whole rather than leaving a half-migrated
database stamped with the old version, and an up-to-date database is
opened without taking the write lock at all.

The fixture builds a schema-version 1 database and asserts the ladder
lands on SCHEMA_VERSION, so it needs no updating on the next bump.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 src/tests/test_review_tracking.py | 113 ++++++++++++++++++++++++++++++++++++++
 1 file changed, 113 insertions(+)

diff --git a/src/tests/test_review_tracking.py b/src/tests/test_review_tracking.py
index 4cde7cdd..6394d332 100644
--- a/src/tests/test_review_tracking.py
+++ b/src/tests/test_review_tracking.py
@@ -1721,6 +1721,119 @@ def _make_blob_tracking_data(
     }
 
 
+class TestMigrationSerialization:
+    """Two processes opening one database must not both migrate it.
+
+    The TUI and a ``b4 review cron`` sweep open the same file, and a
+    pending migration is exactly what both of them find on the first run
+    after an upgrade.  Reading the version and then issuing DDL in
+    autocommit let both decide to migrate: the loser died on `duplicate
+    column name`, or -- having read the version before the winner's DROP
+    landed -- on `no such column`.  busy_timeout cannot help, because
+    neither side ever asked for a lock.
+    """
+
+    @staticmethod
+    def _stale_db(identifier: str) -> str:
+        """A schema-version 1 database, whatever the current version is."""
+        db_path = review_tracking.get_db_path(identifier)
+        raw = sqlite3.connect(db_path)
+        raw.executescript("""
+            CREATE TABLE schema_version (version INTEGER PRIMARY KEY);
+            CREATE TABLE series (
+                track_id INTEGER PRIMARY KEY,
+                change_id TEXT NOT NULL,
+                revision INTEGER NOT NULL,
+                status TEXT DEFAULT 'new',
+                UNIQUE (change_id, revision)
+            );
+        """)
+        raw.execute('INSERT INTO schema_version (version) VALUES (1)')
+        raw.commit()
+        raw.close()
+        return db_path
+
+    def test_two_openers_both_survive_a_pending_migration(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """Both get a usable connection, and the ladder runs once."""
+        import threading
+
+        db_path = self._stale_db('mig-race')
+        errors: list[Exception] = []
+        # Both inside _migrate_db_if_needed at once is the whole point; let
+        # them serialize and the test passes on code that cannot survive
+        # the overlap.
+        barrier = threading.Barrier(2)
+
+        def _open() -> None:
+            barrier.wait()
+            try:
+                review_tracking.get_db('mig-race').close()
+            except Exception as ex:
+                errors.append(ex)
+
+        threads = [threading.Thread(target=_open) for _ in range(2)]
+        for thread in threads:
+            thread.start()
+        for thread in threads:
+            thread.join()
+
+        assert errors == []
+        raw = sqlite3.connect(db_path)
+        rows = raw.execute('SELECT version FROM schema_version').fetchall()
+        raw.close()
+        # One row at the current version: the loser found the work done
+        # rather than redoing it.
+        assert rows == [(review_tracking.SCHEMA_VERSION,)]
+
+    def test_an_interrupted_migration_rolls_back_whole(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Half a migration stamped with the old version is the bad state.
+
+        The next open would resume the ladder at a step whose work is
+        already there and die on it.  sqlite's DDL is transactional, so
+        wrapping the ladder makes the version bump and the schema it
+        describes commit together or not at all.
+        """
+        db_path = self._stale_db('mig-atomic')
+
+        def _boom(conn: sqlite3.Connection) -> None:
+            conn.execute('ALTER TABLE series ADD COLUMN halfway TEXT')
+            raise RuntimeError('interrupted')
+
+        monkeypatch.setattr(review_tracking, '_run_migrations', _boom)
+        with pytest.raises(RuntimeError):
+            review_tracking.get_db('mig-atomic')
+
+        raw = sqlite3.connect(db_path)
+        cols = {row[1] for row in raw.execute('PRAGMA table_info(series)')}
+        version = raw.execute('SELECT version FROM schema_version').fetchone()[0]
+        raw.close()
+        assert 'halfway' not in cols
+        assert version == 1
+
+    def test_an_up_to_date_database_takes_no_write_lock(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """The version is read once without the lock first.
+
+        Every open but the one after an upgrade answers "no migration
+        pending", and that path must not serialize every connection behind
+        a write lock -- a sweep mid-write would otherwise stall the TUI for
+        the whole busy_timeout on every single open.
+        """
+        review_tracking.init_db('mig-current').close()
+        holder = sqlite3.connect(review_tracking.get_db_path('mig-current'))
+        holder.execute('BEGIN IMMEDIATE')
+        try:
+            review_tracking.get_db('mig-current').close()
+        finally:
+            holder.rollback()
+            holder.close()
+
+
 class TestFollowupBlob:
     """Tests for _store_thread_blob() and get_thread_mbox()."""
 

-- 
2.53.0


^ permalink raw reply related	[flat|nested] 26+ messages in thread

* [PATCH RFC v2 09/25] review: track message counts for all revisions of a series
  2026-08-12 21:46 [PATCH RFC v2 00/25] review: track and browse every version of a tracked series Christian Brauner
                   ` (7 preceding siblings ...)
  2026-08-12 21:46 ` [PATCH RFC v2 08/25] review: test the migration serialization Christian Brauner
@ 2026-08-12 21:46 ` Christian Brauner
  2026-08-12 21:46 ` [PATCH RFC v2 10/25] review: give per-change_id state its own table Christian Brauner
                   ` (15 subsequent siblings)
  24 siblings, 0 replies; 26+ messages in thread
From: Christian Brauner @ 2026-08-12 21:46 UTC (permalink / raw)
  To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Christian Brauner (Amutable)

The series table only carries message counts for the tracked revision, so
new mail landing on an older version's thread is invisible.

Move per-revision read state onto the revisions catalog and leave it
there.  message_count, seen_message_count, last_update_check and
last_mail_at become catalog columns (schema v11, backfilled from series
rows including archived upgrade leftovers) and the series copies are
dropped, so no reader has to know which of two copies wins.

Add update_revision_message_counts(), a per-revision poller.  It works
least-recently-checked first, writes counts only when the thread actually
moved, reassembles rethreaded revisions from their per-patch threads, and
caches the thread mbox as a git blob.  The stitched series a range-diff
needs is a different artifact for any version posted with broken
threading, so it gets its own column beside that one.

The poller fetches and counts each thread rather than asking the archive
what is new since it last looked.  public-inbox has no thread-scoped
search, and its only date-range query runs against the whole inbox, which
costs more than the thread it would be probing.  Comparing the fresh
count against the stored one is correct on any public-inbox host.  A
quiet poll still records that it looked, since the rotation is ordered by
that stamp and a column meaning "last changed" would pin the cap to
whichever revisions keep changing.

refresh_message_count() and sync_seen_from_unseen_count() write the
revision's catalog row whether or not a series currently tracks it, so
the thread viewer's badge sync works for any revision.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 src/b4/review/_review.py           |    5 +-
 src/b4/review/tracking.py          | 1475 ++++++++++++++++++++++++++++++------
 src/b4/review_tui/_tracking_app.py |   37 +-
 src/tests/test_review_tracking.py  |   37 +-
 src/tests/test_tui_tracking.py     |   20 +-
 5 files changed, 1326 insertions(+), 248 deletions(-)

diff --git a/src/b4/review/_review.py b/src/b4/review/_review.py
index 98f51363..f566dbfd 100644
--- a/src/b4/review/_review.py
+++ b/src/b4/review/_review.py
@@ -2470,7 +2470,10 @@ def update_series_tracking(
             _known = set()
 
         msgs = b4.mbox.get_extra_series(msgs, direction=1, nocache=True)
-        if current_rev > 1 and not _known:
+        # Discount the tracked revision's own entry.  The v11 backfill gives
+        # every series row one, so a plain "is the catalog empty?" test is
+        # never true again and this one-shot search stopped running at all.
+        if current_rev > 1 and not (_known - {current_rev}):
             msgs = b4.mbox.get_extra_series(
                 msgs, direction=-1, wantvers=list(range(1, current_rev)), nocache=True
             )
diff --git a/src/b4/review/tracking.py b/src/b4/review/tracking.py
index cecfdb9c..7d42b160 100644
--- a/src/b4/review/tracking.py
+++ b/src/b4/review/tracking.py
@@ -15,7 +15,7 @@ import signal
 import sqlite3
 import sys
 import types
-from typing import Any, Dict, List, Optional, Set, Tuple
+from typing import Any, Callable, Dict, List, Optional, Set, Tuple
 
 import b4
 import b4.mbox
@@ -26,7 +26,7 @@ logger = b4.logger
 REVIEW_METADATA_DIR = 'b4-review'
 REVIEW_METADATA_FILE = 'metadata.json'
 
-SCHEMA_VERSION = 10
+SCHEMA_VERSION = 11
 
 SERIES_PATCHES_DDL = """
 CREATE TABLE IF NOT EXISTS series_patches (
@@ -38,12 +38,7 @@ CREATE TABLE IF NOT EXISTS series_patches (
     PRIMARY KEY (change_id, revision, position)
 )"""
 
-SCHEMA_SQL = (
-    """
-CREATE TABLE IF NOT EXISTS schema_version (
-    version INTEGER PRIMARY KEY
-);
-
+SERIES_DDL = """
 CREATE TABLE IF NOT EXISTS series (
     track_id INTEGER PRIMARY KEY,
     change_id TEXT NOT NULL,
@@ -59,16 +54,33 @@ CREATE TABLE IF NOT EXISTS series (
     status TEXT DEFAULT 'new',
     fingerprint TEXT,
     branch_sha TEXT,
-    message_count INT,
-    seen_message_count INT,
-    last_update_check TEXT,
+    -- Per-revision read state (message_count, seen_message_count,
+    -- last_update_check) lives on `revisions` and only there: a series row
+    -- names which revision it tracks, and that revision's catalog row
+    -- answers "how much mail, how much of it read, checked when".  Keeping
+    -- a second copy here is what made every reader restate a COALESCE.
+    --
+    -- last_activity_at stays, and is NOT the catalog's last_mail_at.  It is
+    -- a union stamp -- "when did anything last happen to this series",
+    -- maintainer actions and new mail on the tracked revision alike (see
+    -- _touch_last_mail) -- while last_mail_at is the newest Date: header in
+    -- one specific version's thread, and nothing else.
     last_activity_at TEXT,
     snoozed_until TEXT,
     attestation TEXT DEFAULT 'pending',
     target_branch TEXT,
     is_rethreaded INTEGER DEFAULT 0,
     UNIQUE (change_id, revision)
+)"""
+
+SCHEMA_SQL = (
+    """
+CREATE TABLE IF NOT EXISTS schema_version (
+    version INTEGER PRIMARY KEY
 );
+"""
+    + SERIES_DDL
+    + """;
 
 CREATE TABLE IF NOT EXISTS revisions (
     change_id   TEXT NOT NULL,
@@ -77,14 +89,32 @@ CREATE TABLE IF NOT EXISTS revisions (
     subject     TEXT,
     link        TEXT,
     found_at    TEXT,
+    -- This version's thread as last fetched: the snapshot a poll counted
+    -- and the baseline for "which messages are new".  Freshest wins.
     thread_blob TEXT,
     fingerprint TEXT,
     source      TEXT DEFAULT 'heuristic',
     is_rethreaded INTEGER DEFAULT 0,
+    message_count INT,
+    seen_message_count INT,
+    last_update_check TEXT,
+    -- Newest Date: header in this version's thread.  Deliberately not
+    -- `last_activity_at`: series.last_activity_at is a maintainer-action
+    -- stamp, and one name over two facts is what forced readers to pick a
+    -- direction per column.
+    last_mail_at TEXT,
+    -- The same version as a *series*, stitched by the get_extra_series()
+    -- passes a range-diff runs.  A separate column because it answers a
+    -- different question -- "all the patches", not "all the mail" -- and
+    -- the two disagree exactly when a version was posted with broken
+    -- threading.  Written only when the thread alone will not do, and
+    -- dropped when the thread changes underneath it.
+    series_blob TEXT,
     PRIMARY KEY (change_id, revision)
 );
 
 CREATE INDEX IF NOT EXISTS idx_revisions_fingerprint ON revisions(fingerprint);
+CREATE INDEX IF NOT EXISTS idx_revisions_message_id ON revisions(message_id);
 
 """
     + SERIES_PATCHES_DDL
@@ -233,6 +263,103 @@ def _run_migrations(conn: sqlite3.Connection) -> None:
             conn.execute(
                 'ALTER TABLE revisions ADD COLUMN is_rethreaded INTEGER DEFAULT 0'
             )
+    if version < 11:
+        # Per-revision read state moves onto the revisions catalog, which
+        # becomes its only home: the series table covers the tracked
+        # revision alone, so non-tracked versions had nowhere to keep a
+        # message count or a poll watermark.  The three columns are dropped
+        # from `series` at the end of this block rather than kept in step,
+        # so no reader has to know which copy wins.
+        conn.execute(SERIES_DDL)
+        existing = {row[1] for row in conn.execute('PRAGMA table_info(revisions)')}
+        for coldef in (
+            'message_count INT',
+            'seen_message_count INT',
+            'last_update_check TEXT',
+            'last_mail_at TEXT',
+            'series_blob TEXT',
+        ):
+            if coldef.split()[0] not in existing:
+                conn.execute(f'ALTER TABLE revisions ADD COLUMN {coldef}')
+        # Backfill from the series table -- but only when it carries the
+        # full column set (a degenerate/absent series table has nothing
+        # worth backfilling from).
+        series_cols = {row[1] for row in conn.execute('PRAGMA table_info(series)')}
+        needed = {
+            'change_id',
+            'revision',
+            'message_id',
+            'subject',
+            'added_at',
+            'sent_at',
+            'fingerprint',
+            'is_rethreaded',
+            'message_count',
+            'seen_message_count',
+            'last_update_check',
+            'last_activity_at',
+        }
+        # last_mail_at is deliberately not seeded from series.last_activity_at:
+        # that column also records maintainer actions, so seeding it would
+        # date a version's thread from the last time someone snoozed the
+        # series.  Left NULL until a poll reads a real Date: header.
+        if needed <= series_cols:
+            # Every tracked series (archived rows included) needs a catalog
+            # row so its per-revision counts have somewhere to live.
+            conn.execute(
+                'INSERT OR IGNORE INTO revisions'
+                ' (change_id, revision, message_id, subject, found_at,'
+                '  fingerprint, source, is_rethreaded,'
+                '  message_count, seen_message_count, last_update_check)'
+                ' SELECT change_id, revision, message_id, subject,'
+                "  COALESCE(sent_at, added_at), fingerprint, 'heuristic',"
+                '  COALESCE(is_rethreaded, 0),'
+                '  message_count, seen_message_count, last_update_check'
+                " FROM series WHERE message_id IS NOT NULL AND message_id != ''"
+            )
+            # Seed counts on pre-existing catalog rows from any matching
+            # series row -- the only historical data available.
+            conn.execute(
+                'UPDATE revisions SET'
+                '  message_count = (SELECT s.message_count FROM series s'
+                '   WHERE s.change_id = revisions.change_id'
+                '   AND s.revision = revisions.revision),'
+                '  seen_message_count = (SELECT s.seen_message_count FROM series s'
+                '   WHERE s.change_id = revisions.change_id'
+                '   AND s.revision = revisions.revision),'
+                '  last_update_check = (SELECT s.last_update_check FROM series s'
+                '   WHERE s.change_id = revisions.change_id'
+                '   AND s.revision = revisions.revision)'
+                ' WHERE message_count IS NULL AND EXISTS (SELECT 1 FROM series s'
+                '  WHERE s.change_id = revisions.change_id'
+                '  AND s.revision = revisions.revision)'
+            )
+            # Now that the catalog holds them, retire the series copies.
+            # Two owners is what this schema bump exists to end, and a
+            # column left behind invites the next writer to keep it warm.
+            # Inside the backfill guard on purpose: dropping a copy that
+            # was never carried across would just lose it.
+            #
+            # DROP COLUMN wants sqlite 3.35 (2021).  On anything older the
+            # columns simply stay, unread by everything below -- dead
+            # weight in the row, not a correctness problem, and not worth
+            # a twelve-step table rebuild to reclaim.
+            for col in (
+                'message_count',
+                'seen_message_count',
+                'last_update_check',
+            ):
+                try:
+                    conn.execute(f'ALTER TABLE series DROP COLUMN {col}')
+                except sqlite3.OperationalError as ex:
+                    logger.debug('Could not drop series.%s: %s', col, ex)
+        # Matching a posting by message-id became a hot lookup at v11 --
+        # [l], [o] and the conflict check all run it -- and it was a full
+        # table scan, unlike its fingerprint twin.
+        conn.execute(
+            'CREATE INDEX IF NOT EXISTS idx_revisions_message_id'
+            ' ON revisions(message_id)'
+        )
     # Not an UPDATE: `version` is the primary key, so an UPDATE writes
     # nothing at all against an empty table -- and the read above maps "no
     # row" to version 0, so such a database would re-run the whole ladder
@@ -491,13 +618,18 @@ def add_series_to_db(
 ) -> int:
     """Add a series to the tracking database. Returns the track_id.
 
+    The tracked revision is catalogued as part of adding the series: read
+    state lives on `revisions` and only there, so a series row without a
+    catalog entry has nowhere to keep a message count, and the first
+    writer's UPDATE would match no row and drop the number on the floor.
+
     On conflict the identity fields converge instead of overwriting: a
     caller that does not know the Patchwork id or the fingerprint leaves
-    an existing one in place.  Re-adds come from callers that never
-    learned those fields -- the Patchwork tracker attaching its id,
+    an existing one in place, and ``is_rethreaded`` is sticky, matching
+    the catalog's :func:`add_revision`.  Re-adds come from callers that
+    never learned those fields -- the Patchwork tracker attaching its id,
     rescan_branches replaying a branch -- and each used to wipe whatever
-    the others had recorded.  ``is_rethreaded`` is sticky for the same
-    reason, matching the catalog's :func:`add_revision`.
+    the others had recorded.
 
     ``is_rethreaded`` describes the posting, so the catalog's answer for
     this revision wins over the argument on the way in.  The argument is
@@ -549,6 +681,7 @@ def add_series_to_db(
         ),
     )
     track_id = cursor.fetchone()[0]
+    _ensure_catalog_row(conn, change_id, revision)
     conn.commit()
     return int(track_id)
 
@@ -958,19 +1091,31 @@ def get_all_tracked_series(identifier: str) -> list[dict[str, Any]]:
 
     Returns a list of dicts with keys: track_id, change_id, revision, subject,
     sender_name, sender_email, sent_at, added_at, status, num_patches,
-    message_id, pw_series_id, message_count, seen_message_count.
+    message_id, pw_series_id, message_count, seen_message_count,
+    last_activity_at, attestation, target_branch, is_rethreaded,
+    snoozed_until, fingerprint, last_update_check, last_mail_at.
+
+    The counts come from the tracked revision's catalog row, which is the
+    only place they are kept.  ``last_activity_at`` is the series' own
+    column and means something else -- when the maintainer last acted on
+    it -- so it is read straight off `series`; the tracked version's newest
+    mail is ``last_mail_at``, beside the counts.
     """
     if not db_exists(identifier):
         return []
     try:
         conn = get_db(identifier)
         cursor = conn.execute("""
-            SELECT track_id, change_id, revision, subject, sender_name, sender_email,
-                   sent_at, added_at, status, num_patches, message_id, pw_series_id,
-                   message_count, seen_message_count, last_activity_at, attestation,
-                   target_branch, is_rethreaded, snoozed_until
-            FROM series
-            ORDER BY added_at DESC
+            SELECT s.track_id, s.change_id, s.revision, s.subject, s.sender_name,
+                   s.sender_email, s.sent_at, s.added_at, s.status, s.num_patches,
+                   s.message_id, s.pw_series_id,
+                   r.message_count, r.seen_message_count,
+                   s.last_activity_at, s.attestation,
+                   s.target_branch, s.is_rethreaded, s.snoozed_until, s.fingerprint,
+                   r.last_update_check, r.last_mail_at
+            FROM series s LEFT JOIN revisions r
+              ON r.change_id = s.change_id AND r.revision = s.revision
+            ORDER BY s.added_at DESC
         """)
         result = []
         for row in cursor.fetchall():
@@ -995,6 +1140,9 @@ def get_all_tracked_series(identifier: str) -> list[dict[str, Any]]:
                     'target_branch': row[16],
                     'is_rethreaded': bool(row[17]),
                     'snoozed_until': row[18],
+                    'fingerprint': row[19],
+                    'last_update_check': row[20],
+                    'last_mail_at': row[21],
                 }
             )
         conn.close()
@@ -1024,9 +1172,19 @@ def add_revision(
     source: str = 'heuristic',
     is_rethreaded: bool = False,
     subject_from_cover: bool = False,
+    found_at: Optional[str] = None,
 ) -> None:
     """Insert a revision record, ignoring core fields if already present.
 
+    *found_at* defaults to now, which is right for a revision discovered
+    on the wire but wrong for one being backfilled as it is retired — the
+    oldest version would then carry the newest date.  Callers that know
+    when the revision actually arrived pass it, so the column reads as
+    "when this revision was posted, as accurately as we know" rather than
+    "when b4 first saw it"; rows written before that distinction existed
+    still hold the discovery timestamp, which for a version found by the
+    forward search is within a sweep interval of the posting anyway.
+
     The core fields (message_id, subject, link, found_at) follow first-wins
     semantics — re-adding an existing revision leaves them untouched.  Four
     fields are reconciled on re-add, however:
@@ -1042,7 +1200,8 @@ def add_revision(
       first-patch fallback recorded before the cover was seen (bug 8bb6e4c),
       unless the stored row's provenance outranks the incoming one.
     """
-    found_at = datetime.datetime.now(datetime.timezone.utc).isoformat()
+    if not found_at:
+        found_at = datetime.datetime.now(datetime.timezone.utc).isoformat()
     conn.execute(
         """INSERT OR IGNORE INTO revisions
         (change_id, revision, message_id, subject, link, found_at,
@@ -1103,17 +1262,51 @@ def add_revision(
 
 def set_revision_thread_blob(
     conn: sqlite3.Connection, change_id: str, revision: int, blob_sha: str
-) -> None:
+) -> bool:
     """Record the git blob SHA of the cached mbox thread for a revision.
 
     The blob may later become unreachable (GC'd), so callers that read this
     value must tolerate a missing blob and fall back to a lore fetch.
+
+    Returns False when the catalog has no row for this revision -- the
+    tracked revision is not guaranteed one, so a caller handed a
+    synthesized entry would otherwise take a no-op for a stored blob and
+    refetch on every call.
+
+    A different thread drops any stitched ``series_blob`` built from the
+    old one: the patch that made the version unstitchable may have just
+    landed, and re-running the stitch once per thread change is the whole
+    cost of finding out.  Only an identical SHA -- and the blob is
+    content-addressed, so that means an identical thread -- keeps it, which
+    is what stops a quiet sweep from throwing the stitch away.
     """
-    conn.execute(
-        'UPDATE revisions SET thread_blob = ? WHERE change_id = ? AND revision = ?',
+    cursor = conn.execute(
+        'UPDATE revisions SET thread_blob = ?,'
+        ' series_blob = CASE WHEN thread_blob IS NULL OR thread_blob = ?'
+        '  THEN series_blob END'
+        ' WHERE change_id = ? AND revision = ?',
+        (blob_sha, blob_sha, change_id, revision),
+    )
+    conn.commit()
+    return cursor.rowcount > 0
+
+
+def set_revision_series_blob(
+    conn: sqlite3.Connection, change_id: str, revision: int, blob_sha: str
+) -> bool:
+    """Record the git blob SHA of a revision's stitched series mbox.
+
+    The counterpart of :func:`set_revision_thread_blob` for the *series*
+    view of a version: what the get_extra_series() passes reassembled, kept
+    because a thread that does not hold the whole series cannot be made to
+    yield one however often it is re-read.  Same GC caveat.
+    """
+    cursor = conn.execute(
+        'UPDATE revisions SET series_blob = ? WHERE change_id = ? AND revision = ?',
         (blob_sha, change_id, revision),
     )
     conn.commit()
+    return cursor.rowcount > 0
 
 
 def add_series_patches(
@@ -1181,6 +1374,12 @@ def build_known_revisions(
             entry['fingerprint'] = r['fingerprint']
         if r.get('source'):
             entry['source'] = r['source']
+        # Carried so a catalog rebuilt from the branch keeps each version's
+        # posting date.  Without it add_revision() defaults every replayed
+        # row to now(), and the oldest version comes back dated newest --
+        # exactly what the found_at parameter exists to prevent.
+        if r.get('found_at'):
+            entry['found-at'] = r['found_at']
         if r.get('is_rethreaded'):
             entry['is-rethreaded'] = True
             entry['patches'] = [
@@ -1225,6 +1424,7 @@ def record_known_revisions(
             fingerprint=entry.get('fingerprint'),
             source=entry.get('source') or 'heuristic',
             is_rethreaded=is_rethreaded,
+            found_at=entry.get('found-at'),
         )
         patches = entry.get('patches') or []
         if is_rethreaded and patches:
@@ -1302,21 +1502,34 @@ _REVISION_COLS = (
     'link',
     'found_at',
     'thread_blob',
+    'series_blob',
     'fingerprint',
     'source',
     'is_rethreaded',
+    'message_count',
+    'seen_message_count',
+    'last_update_check',
+    'last_mail_at',
 )
 
+# No join and no COALESCE: every column below is owned by `revisions` and
+# stored nowhere else, so there is no second copy to prefer, no archived
+# series row to exclude, and no per-column direction to remember.
+# (`series.is_rethreaded` is the one field still denormalized; see
+# update_series_revision, which re-reads it from the catalog.)
 _REVISION_SELECT = (
-    'SELECT change_id, revision, message_id, subject, link, found_at,'
-    ' thread_blob, fingerprint, source, is_rethreaded FROM revisions'
+    'SELECT r.change_id, r.revision, r.message_id, r.subject, r.link,'
+    ' r.found_at, r.thread_blob, r.series_blob, r.fingerprint, r.source,'
+    ' r.is_rethreaded, r.message_count, r.seen_message_count,'
+    ' r.last_update_check, r.last_mail_at'
+    ' FROM revisions r'
 )
 
 
 def get_revisions(conn: sqlite3.Connection, change_id: str) -> list[dict[str, Any]]:
     """Return all known revisions for a change_id, ordered ascending."""
     cursor = conn.execute(
-        _REVISION_SELECT + ' WHERE change_id = ? ORDER BY revision ASC',
+        _REVISION_SELECT + ' WHERE r.change_id = ? ORDER BY r.revision ASC',
         (change_id,),
     )
     return [dict(zip(_REVISION_COLS, row)) for row in cursor.fetchall()]
@@ -1334,7 +1547,7 @@ def find_revision_by_fingerprint(
     if not fingerprint:
         return None
     row = conn.execute(
-        _REVISION_SELECT + ' WHERE fingerprint = ? LIMIT 1',
+        _REVISION_SELECT + ' WHERE r.fingerprint = ? ORDER BY r.change_id LIMIT 1',
         (fingerprint,),
     ).fetchone()
     if row is None:
@@ -1513,27 +1726,55 @@ def absorb_series_as_revision(
     into_change_id: str,
     stray_change_id: str,
     revision: int,
+    stray_revision: Optional[int] = None,
 ) -> bool:
     """Re-home a stray stand-alone series as a revision of another change_id.
 
     When a posting is independently tracked under its own ``stray_change_id``
     but is really revision *revision* of ``into_change_id`` (e.g. a v-bump
     auto-discovery failed to connect), fold it in: record it as a manually
-    linked revision, copy its patches, and delete the stray series wholesale
-    (its ``series``, ``revisions``, and ``series_patches`` rows).
+    linked revision, copy its patches, and delete the stray's rows for the
+    absorbed revision.  The stray's *other* versions are left alone while a
+    series row still tracks them -- a stray tracked at v1, v2 and v3 that is
+    linked at v2 keeps v1 and v3.  Once its last series row is gone, its
+    ``revisions``/``series_patches`` leftovers are re-homed under the target
+    rather than dropped: they are postings of this same series, and their
+    per-patch message-ids cannot be re-derived from the mailing list (see
+    :func:`build_known_revisions`).
+
+    *stray_revision* names which of the stray's revisions is the one being
+    absorbed.  Callers that matched a specific revision (by fingerprint,
+    say) must pass it: a stray tracked across several versions otherwise
+    contributes whichever row the database happens to return first, and
+    the absorbed revision arrives carrying another version's message-id
+    and counts.  A catalog row is enough on its own: an upgraded stray keeps
+    the version it left behind in the catalog with no series row, and
+    refusing that would fall through to a duplicate record of the same
+    message-id under two change_ids.
 
     The target's other revisions are left untouched.  Returns True if a stray
-    series was absorbed, or False if none existed (making the call a safe
-    no-op, including when invoked a second time).
+    revision was absorbed, or False if none existed (making the call a safe
+    no-op, including when invoked a second time).  Callers must act on the
+    return: a False means the revision is still unrecorded.
     """
-    srow = conn.execute(
-        'SELECT revision, subject, message_id, fingerprint, is_rethreaded FROM series'
-        ' WHERE change_id = ?',
-        (stray_change_id,),
-    ).fetchone()
-    if srow is None:
+    if stray_revision is not None:
+        stray_rev: Optional[int] = int(stray_revision)
+        srow = conn.execute(
+            'SELECT revision, subject, message_id, fingerprint, is_rethreaded'
+            ' FROM series WHERE change_id = ? AND revision = ?',
+            (stray_change_id, stray_rev),
+        ).fetchone()
+    else:
+        srow = conn.execute(
+            'SELECT revision, subject, message_id, fingerprint, is_rethreaded'
+            ' FROM series WHERE change_id = ?'
+            # Deterministic, and live rows before upgrade leftovers.
+            " ORDER BY COALESCE(status, 'new') = 'archived', revision DESC",
+            (stray_change_id,),
+        ).fetchone()
+        stray_rev = srow[0] if srow is not None else None
+    if stray_rev is None:
         return False
-    stray_rev = srow[0]
 
     # Prefer the per-revision record for link/fingerprint, falling back to the
     # series row when the stray was never recorded in the revisions table.
@@ -1545,13 +1786,15 @@ def absorb_series_as_revision(
     # Treat the stray as rethreaded if either its series row or its
     # per-revision record says so — both are set when tracked via --rethread,
     # but be defensive about a partially-populated stray.
-    series_rt = bool(srow[4])
+    series_rt = bool(srow[4]) if srow is not None else False
     if rrow is not None:
         message_id, subject, link, fingerprint = rrow[0], rrow[1], rrow[2], rrow[3]
         is_rethreaded = bool(rrow[4]) or series_rt
-    else:
+    elif srow is not None:
         message_id, subject, link, fingerprint = srow[2], srow[1], None, srow[3]
         is_rethreaded = series_rt
+    else:
+        return False
 
     add_revision(
         conn,
@@ -1565,6 +1808,46 @@ def absorb_series_as_revision(
         is_rethreaded=is_rethreaded,
     )
 
+    # Read state follows the posting across change_ids -- the stray's rows
+    # are deleted below.  Still a copy, because this is the one move that
+    # is not a series row changing which revision it points at: the same
+    # posting is being re-filed under a different change_id, so its counts
+    # have to come along.  Merged, not gated on the target being blank: the
+    # forward sweep may have catalogued the same posting under the target
+    # and first-fetched it to seen = count, and skipping the copy then
+    # deletes the stray's real read state with the stray -- unread mail
+    # rendered read.  The totals take the larger side (never downgrade),
+    # the seen counts the smaller (never lose a badge), the stamps the
+    # newer; NULLs lose to values on every column.
+    conn.execute(
+        'UPDATE revisions SET'
+        '  message_count = MAX(COALESCE((SELECT message_count FROM revisions'
+        '   WHERE change_id = :scid AND revision = :srev), message_count),'
+        '   COALESCE(message_count, (SELECT message_count FROM revisions'
+        '   WHERE change_id = :scid AND revision = :srev))),'
+        '  seen_message_count = MIN(COALESCE((SELECT seen_message_count'
+        '   FROM revisions WHERE change_id = :scid AND revision = :srev),'
+        '   seen_message_count),'
+        '   COALESCE(seen_message_count, (SELECT seen_message_count'
+        '   FROM revisions WHERE change_id = :scid AND revision = :srev))),'
+        '  last_update_check = MAX(COALESCE((SELECT last_update_check'
+        '   FROM revisions WHERE change_id = :scid AND revision = :srev),'
+        '   last_update_check),'
+        '   COALESCE(last_update_check, (SELECT last_update_check'
+        '   FROM revisions WHERE change_id = :scid AND revision = :srev))),'
+        '  last_mail_at = MAX(COALESCE((SELECT last_mail_at FROM revisions'
+        '   WHERE change_id = :scid AND revision = :srev), last_mail_at),'
+        '   COALESCE(last_mail_at, (SELECT last_mail_at FROM revisions'
+        '   WHERE change_id = :scid AND revision = :srev)))'
+        ' WHERE change_id = :icid AND revision = :irev',
+        {
+            'scid': stray_change_id,
+            'srev': stray_rev,
+            'icid': into_change_id,
+            'irev': revision,
+        },
+    )
+
     # Copy the stray's patches onto the target revision, replacing any present.
     conn.execute(
         'DELETE FROM series_patches WHERE change_id = ? AND revision = ?',
@@ -1577,10 +1860,68 @@ def absorb_series_as_revision(
         (into_change_id, revision, stray_change_id, stray_rev),
     )
 
-    # Remove the stray series entirely.
-    conn.execute('DELETE FROM series WHERE change_id = ?', (stray_change_id,))
-    conn.execute('DELETE FROM revisions WHERE change_id = ?', (stray_change_id,))
-    conn.execute('DELETE FROM series_patches WHERE change_id = ?', (stray_change_id,))
+    # Remove only the absorbed revision: the stray's other versions are
+    # postings in their own right, and their per-patch message-ids are
+    # unrecoverable once dropped.
+    for table in ('series', 'revisions', 'series_patches'):
+        conn.execute(
+            f'DELETE FROM {table} WHERE change_id = ? AND revision = ?',
+            (stray_change_id, stray_rev),
+        )
+    remaining = conn.execute(
+        'SELECT COUNT(*) FROM series WHERE change_id = ?', (stray_change_id,)
+    ).fetchone()[0]
+    if not remaining:
+        # Last series row absorbed, so the stray's change_id is about to
+        # become unreachable -- but its other catalogued versions are
+        # postings of this same series, and a rethreaded one's per-patch
+        # message-ids cannot be re-derived from the list.  Re-home them
+        # under the target; a version the target already catalogs keeps
+        # the target's row, rescuing only a patch list the target lacks.
+        for (v,) in conn.execute(
+            'SELECT revision FROM revisions WHERE change_id = ?',
+            (stray_change_id,),
+        ).fetchall():
+            claimed = conn.execute(
+                'SELECT 1 FROM revisions WHERE change_id = ? AND revision = ?',
+                (into_change_id, v),
+            ).fetchone()
+            has_patches = conn.execute(
+                'SELECT 1 FROM series_patches WHERE change_id = ? AND revision = ?'
+                ' LIMIT 1',
+                (into_change_id, v),
+            ).fetchone()
+            if claimed is None:
+                conn.execute(
+                    'UPDATE revisions SET change_id = ?'
+                    ' WHERE change_id = ? AND revision = ?',
+                    (into_change_id, stray_change_id, v),
+                )
+            if has_patches is None:
+                moved = conn.execute(
+                    'UPDATE series_patches SET change_id = ?'
+                    ' WHERE change_id = ? AND revision = ?',
+                    (into_change_id, stray_change_id, v),
+                )
+                if claimed is not None and moved.rowcount:
+                    # A rescued patch list is only ever read behind the
+                    # rethread flag, so a rethreaded stray's flag comes
+                    # with it onto the row the target kept.
+                    rt = conn.execute(
+                        'SELECT is_rethreaded FROM revisions'
+                        ' WHERE change_id = ? AND revision = ?',
+                        (stray_change_id, v),
+                    ).fetchone()
+                    if rt is not None and rt[0]:
+                        conn.execute(
+                            'UPDATE revisions SET is_rethreaded = 1'
+                            ' WHERE change_id = ? AND revision = ?',
+                            (into_change_id, v),
+                        )
+        conn.execute('DELETE FROM revisions WHERE change_id = ?', (stray_change_id,))
+        conn.execute(
+            'DELETE FROM series_patches WHERE change_id = ?', (stray_change_id,)
+        )
     conn.commit()
     return True
 
@@ -1674,7 +2015,13 @@ def record_linked_revision(
     fingerprint = lser.fingerprint
     stray = find_revision_by_fingerprint(conn, fingerprint)
     if stray is not None and stray['change_id'] != change_id:
-        absorb_series_as_revision(conn, change_id, stray['change_id'], revision)
+        absorb_series_as_revision(
+            conn,
+            change_id,
+            stray['change_id'],
+            revision,
+            stray_revision=stray.get('revision'),
+        )
         result['absorbed'] = True
     else:
         message_id = ref_msg.msgid
@@ -1802,23 +2149,11 @@ def get_all_revisions_grouped(
     conn: sqlite3.Connection,
 ) -> dict[str, list[dict[str, Any]]]:
     """Return {change_id: [rev_dicts]} for all change_ids, ordered ascending."""
-    cols = (
-        'change_id',
-        'revision',
-        'message_id',
-        'subject',
-        'link',
-        'found_at',
-        'thread_blob',
-    )
-    cursor = conn.execute(
-        'SELECT change_id, revision, message_id, subject, link, found_at, thread_blob '
-        'FROM revisions ORDER BY change_id, revision ASC'
-    )
+    cursor = conn.execute(_REVISION_SELECT + ' ORDER BY r.change_id, r.revision ASC')
     result: dict[str, list[dict[str, Any]]] = {}
     for row in cursor.fetchall():
-        entry: dict[str, Any] = dict(zip(cols, row))
-        result.setdefault(row[0], []).append(entry)
+        entry: dict[str, Any] = dict(zip(_REVISION_COLS, row))
+        result.setdefault(entry['change_id'], []).append(entry)
     return result
 
 
@@ -1852,6 +2187,10 @@ def update_series_status(
 
     Always stamps last_activity_at with the current UTC time so that
     within-group sort reflects maintainer activity as well as thread activity.
+
+    Archiving needs no count bookkeeping: read state was never on this row
+    to begin with, so the revision an archived series leaves behind still
+    owns its own counts and unread badge.
     """
     now = datetime.datetime.now(datetime.timezone.utc).isoformat()
     if revision is not None:
@@ -1880,27 +2219,61 @@ def update_series_revision(
 
     Used when a not-yet-checked-out series should track a different
     revision without going through the full review checkout flow.
-    Updates the revision, message_id, and optionally subject columns.
-    Resets message_count and seen_message_count so the next update
-    fetches fresh counts for the new revision's thread.
+
+    Nothing is parked and nothing is cleared: read state belongs to the
+    revisions, not to this row, so the version being left behind keeps its
+    counts, its watermark and its unread badge simply by not being touched,
+    and the incoming one arrives with whatever the poller has already
+    learned about it.  ``is_rethreaded`` is re-read from the catalog for the
+    incoming revision, since it describes a posting rather than the series.
+
+    Both revisions are catalogued: the outgoing one because its read state
+    has nowhere else to live once this row stops naming it, the incoming
+    one because that is the rule every path pointing a series row at a
+    revision follows -- callers happen to pick the target out of the
+    catalog today, which is not something this function can require.
     """
     now = datetime.datetime.now(datetime.timezone.utc).isoformat()
+    _ensure_catalog_row(conn, change_id, old_revision)
+
+    rethreaded = 'COALESCE((SELECT is_rethreaded FROM revisions'
+    rethreaded += ' WHERE change_id = ? AND revision = ?), 0)'
     if new_subject is not None:
         conn.execute(
             'UPDATE series SET revision = ?, message_id = ?, subject = ?,'
-            ' message_count = NULL, seen_message_count = NULL,'
-            ' last_activity_at = ?'
+            ' last_activity_at = ?,'
+            f' is_rethreaded = {rethreaded}'
             ' WHERE change_id = ? AND revision = ?',
-            (new_revision, new_message_id, new_subject, now, change_id, old_revision),
+            (
+                new_revision,
+                new_message_id,
+                new_subject,
+                now,
+                change_id,
+                new_revision,
+                change_id,
+                old_revision,
+            ),
         )
     else:
         conn.execute(
             'UPDATE series SET revision = ?, message_id = ?,'
-            ' message_count = NULL, seen_message_count = NULL,'
-            ' last_activity_at = ?'
+            ' last_activity_at = ?,'
+            f' is_rethreaded = {rethreaded}'
             ' WHERE change_id = ? AND revision = ?',
-            (new_revision, new_message_id, now, change_id, old_revision),
+            (
+                new_revision,
+                new_message_id,
+                now,
+                change_id,
+                new_revision,
+                change_id,
+                old_revision,
+            ),
         )
+    # After the UPDATE, so the row this seeds from already names the
+    # incoming revision and its message-id.
+    _ensure_catalog_row(conn, change_id, new_revision)
     conn.commit()
 
 
@@ -2236,6 +2609,36 @@ def get_review_branches(topdir: Optional[str] = None) -> list[str]:
     return b4.git_get_command_lines(topdir, gitargs)
 
 
+def _fetch_thread_msgs(message_id: str) -> Optional[List[Any]]:
+    """Fetch a full thread by message-id for counting or discovery.
+
+    Goes through :func:`b4.get_pi_thread_by_msgid` rather than talking to
+    the LoreNode directly, for three reasons:
+
+    - ``nocache=True``.  The node's mbox cache has a ten-minute TTL, and
+      the count *is* the freshness signal here — a cached read would
+      report a thread as quiet when the mail that prompted the maintainer
+      to press 'u' has already landed.
+    - Strict threading.  Every other writer of ``message_count`` (the
+      series machinery, the thread viewer) counts the strict thread, so
+      counting the raw mbox here would make the two disagree and leave a
+      residual unread badge that never clears.
+    - Cancellation.  It catches only ``RemoteError``, letting
+      ``OperationCancelledError`` propagate to the sweep.
+
+    Returns None on failure or when offline.
+    """
+    if not b4.can_network:
+        return None
+    try:
+        return b4.get_pi_thread_by_msgid(message_id, nocache=True, quiet=True)
+    except liblore.OperationCancelledError:
+        raise
+    except Exception as ex:
+        logger.debug('Could not fetch thread for %s: %s', message_id, ex)
+        return None
+
+
 def _latest_date_from_msgs(msgs: List[Any]) -> Optional[str]:
     """Return the most recent Date header from EmailMessage objects as ISO timestamp."""
     latest: Optional[datetime.datetime] = None
@@ -2256,6 +2659,168 @@ def _latest_date_from_msgs(msgs: List[Any]) -> Optional[str]:
     return latest.astimezone(datetime.timezone.utc).isoformat()
 
 
+def _ensure_catalog_row(
+    conn: sqlite3.Connection, change_id: str, revision: int
+) -> None:
+    """Make sure the revision that *change_id* tracks has a catalog row.
+
+    Read state lives on `revisions` and only there, so a series row whose
+    revision was never catalogued has nowhere to put a count -- the UPDATE
+    would match nothing and the number would vanish.  Manual linking can
+    record only newer versions, so this is not hypothetical.  Seeded from
+    the series row, which is where the identifying fields came from.
+    """
+    conn.execute(
+        'INSERT OR IGNORE INTO revisions'
+        ' (change_id, revision, message_id, subject, found_at, fingerprint,'
+        '  source, is_rethreaded)'
+        ' SELECT change_id, revision, message_id, subject,'
+        "  COALESCE(sent_at, added_at), fingerprint, 'heuristic',"
+        '  COALESCE(is_rethreaded, 0) FROM series'
+        ' WHERE change_id = ? AND revision = ? AND message_id IS NOT NULL'
+        " AND message_id != ''",
+        (change_id, revision),
+    )
+
+
+def _touch_last_mail(
+    conn: sqlite3.Connection,
+    change_id: str,
+    revision: int,
+    last_mail: Optional[str],
+) -> None:
+    """Record the newest Date: header seen in a revision's thread.
+
+    Forward only: a rethreaded revision whose member thread will not fetch
+    loses that member's newest reply from the union while other members
+    raise the total, which otherwise walks the date backwards.
+
+    Also bumps the series' own ``last_activity_at``, which is a different
+    column answering a different question -- "when did anything last happen
+    to this series", maintainer actions included -- and which the tracking
+    list's age column has always advanced on new mail.
+    """
+    if not last_mail:
+        return
+    conn.execute(
+        'UPDATE revisions SET last_mail_at = ?'
+        ' WHERE change_id = ? AND revision = ?'
+        ' AND COALESCE(last_mail_at, ?) <= ?',
+        (last_mail, change_id, revision, last_mail, last_mail),
+    )
+    conn.execute(
+        'UPDATE series SET last_activity_at = ?'
+        ' WHERE change_id = ? AND revision = ?'
+        ' AND COALESCE(last_activity_at, ?) <= ?',
+        (last_mail, change_id, revision, last_mail, last_mail),
+    )
+
+
+def _read_counts(
+    conn: sqlite3.Connection, change_id: str, revision: int
+) -> Optional[Tuple[Optional[int], Optional[int]]]:
+    """A revision's stored ``(message_count, seen_message_count)``.
+
+    One lookup against the one table that holds them.  There is nothing to
+    adopt, park or carry across on an upgrade: a series row re-pointed at
+    another revision simply reads that revision's row, which the poller may
+    already have filled in, and the row it left keeps its own counts.
+
+    None means there is no catalog row at all, which is not the same as a
+    row that has never been counted -- the writes below would match nothing
+    and report a stored count that went nowhere.
+    """
+    row = conn.execute(
+        'SELECT message_count, seen_message_count FROM revisions'
+        ' WHERE change_id = ? AND revision = ?',
+        (change_id, revision),
+    ).fetchone()
+    if row is None:
+        return None
+    return row[0], row[1]
+
+
+def _write_counts(
+    conn: sqlite3.Connection,
+    change_id: str,
+    revision: int,
+    message_count: int,
+    seen_message_count: Optional[int],
+    now: str,
+) -> None:
+    """Store a revision's counts and stamp the check time.
+
+    The one statement behind every writer of the pair, as
+    :func:`_counts_after_fetch` is the one rule behind their decisions:
+    three hand-written copies of it drifted apart once already.  A
+    *seen_message_count* of None leaves the stored value alone.
+
+    Does not commit -- callers pair this with :func:`_touch_last_mail`
+    and close the transaction themselves.
+    """
+    conn.execute(
+        'UPDATE revisions SET message_count = ?,'
+        ' seen_message_count = COALESCE(?, seen_message_count),'
+        ' last_update_check = ?'
+        ' WHERE change_id = ? AND revision = ?',
+        (message_count, seen_message_count, now, change_id, revision),
+    )
+
+
+def _stamp_check(
+    conn: sqlite3.Connection, change_id: str, revision: int, now: str
+) -> None:
+    """Record that a revision's thread was looked at, counts untouched.
+
+    ``last_update_check`` means "last checked", not "last changed": the
+    poller's least-recently-attempted rotation depends on a quiet poll --
+    and a failed one -- advancing it, or a revision that never changes
+    keeps sorting to the front and starves the rest.
+    """
+    conn.execute(
+        'UPDATE revisions SET last_update_check = ?'
+        ' WHERE change_id = ? AND revision = ?',
+        (now, change_id, revision),
+    )
+
+
+def _counts_after_fetch(
+    old_count: Optional[int], old_seen: Optional[int], count: int
+) -> Optional[Tuple[int, Optional[int]]]:
+    """Decide what a freshly fetched total means for one revision's counts.
+
+    The single rule behind every writer of a ``message_count`` pair -- the
+    series sweep, the per-revision poll and the thread viewer's refresh --
+    which drifted apart once and left two of the three clamping read state
+    away on a partial fetch.
+
+    Returns the ``(message_count, seen_message_count)`` to store, where a
+    seen of None means leave it as it is, or None to store no counts at
+    all.  Only an equal total stores nothing: it carries no information.
+
+    A shrink is recorded, with seen capped to the new total.  Threads do
+    genuinely shrink -- dedup variation, mail removed from the archive --
+    and refusing the smaller number turns the stored count into a stale
+    high watermark that swallows every following total up to it: new
+    replies then arrive under the old number and never raise a badge, and
+    nothing ever corrects it, since the one repair path (the thread
+    viewer's seen sync) runs only when the maintainer opens the thread the
+    missing badge was meant to point at.  The price is that a *partial*
+    fetch recorded here can raise a transient badge for mail already read
+    once the complete thread comes back; that badge clears on open, which
+    is recoverable in a way a permanently suppressed one is not.
+    """
+    if old_count is None:
+        # Never counted: seen = count, so nothing badges mail that predates
+        # tracking.
+        return count, count
+    if count == old_count:
+        return None
+    if old_seen is not None and old_seen > count:
+        return count, count
+    return count, None
+
+
 def update_message_count_from_msgs(
     conn: sqlite3.Connection,
     change_id: str,
@@ -2278,61 +2843,122 @@ def update_message_count_from_msgs(
     """
     now = datetime.datetime.now(datetime.timezone.utc).isoformat()
     count = len(msgs)
-    last_activity = _latest_date_from_msgs(msgs)
-
-    row = conn.execute(
-        'SELECT message_count, seen_message_count FROM series'
-        ' WHERE change_id = ? AND revision = ?',
-        (change_id, revision),
-    ).fetchone()
-    existing_count = row['message_count'] if row else None
+    last_mail = _latest_date_from_msgs(msgs)
+    _ensure_catalog_row(conn, change_id, revision)
+    stored = _read_counts(conn, change_id, revision)
+    if stored is None:
+        # No catalog row and none could be seeded, so there is nowhere to
+        # put a count.  Reported rather than written into the void.
+        logger.debug('No catalog row for %s v%d, not counting', change_id, revision)
+        return False
+    existing_count, existing_seen = stored
 
-    if existing_count is None:
-        # First fetch — initialise seen = count (no badge yet)
-        conn.execute(
-            'UPDATE series'
-            ' SET message_count = ?, seen_message_count = ?,'
-            '     last_update_check = ?, last_activity_at = ?'
-            ' WHERE change_id = ? AND revision = ?',
-            (count, count, now, last_activity, change_id, revision),
-        )
-    elif count != existing_count:
-        # Count changed — update count but not seen (badge will appear),
-        # save for any already-read new messages reported by the caller
-        if seen_bump > 0:
-            new_seen = min(count, (row['seen_message_count'] or 0) + seen_bump)
-            conn.execute(
-                'UPDATE series'
-                ' SET message_count = ?, seen_message_count = ?,'
-                '     last_update_check = ?,'
-                '     last_activity_at = COALESCE(?, last_activity_at)'
-                ' WHERE change_id = ? AND revision = ?',
-                (count, new_seen, now, last_activity, change_id, revision),
-            )
-        else:
-            conn.execute(
-                'UPDATE series'
-                ' SET message_count = ?, last_update_check = ?,'
-                '     last_activity_at = COALESCE(?, last_activity_at)'
-                ' WHERE change_id = ? AND revision = ?',
-                (count, now, last_activity, change_id, revision),
-            )
-    else:
-        # No change — just stamp the check time, skip commit
-        conn.execute(
-            'UPDATE series SET last_update_check = ?'
-            ' WHERE change_id = ? AND revision = ?',
-            (now, change_id, revision),
-        )
+    verdict = _counts_after_fetch(existing_count, existing_seen, count)
+    if verdict is None:
+        # Unchanged total: stamp the check time and leave the counts --
+        # and the cached thread, which this writer overwrites rather than
+        # merging -- alone.
+        _stamp_check(conn, change_id, revision, now)
         conn.commit()
         return False
 
+    new_count, new_seen = verdict
+    if new_seen is None and seen_bump > 0:
+        # New-to-the-thread messages the caller already read (its own
+        # replies), so they must not raise a badge.
+        new_seen = min(new_count, (existing_seen or 0) + seen_bump)
+    _write_counts(conn, change_id, revision, new_count, new_seen, now)
+    _touch_last_mail(conn, change_id, revision, last_mail)
     conn.commit()
     if topdir and msgs:
         _store_thread_blob(topdir, change_id, msgs)
     return True
 
 
+def _write_mbox_blob(topdir: str, msgs: List[Any]) -> Optional[str]:
+    """Serialize msgs to mboxrd and write as a git blob; return the SHA."""
+    import io
+
+    buf = io.BytesIO()
+    b4.save_mboxrd_mbox(msgs, buf)
+    mbox_bytes = buf.getvalue()
+    if not mbox_bytes:
+        return None
+
+    ecode, out = b4.git_run_command(
+        topdir, ['hash-object', '-w', '--stdin'], stdin=mbox_bytes
+    )
+    if ecode != 0:
+        return None
+    return str(out.strip())
+
+
+def store_revision_thread_blob(
+    conn: sqlite3.Connection,
+    topdir: str,
+    change_id: str,
+    revision: int,
+    msgs: List[Any],
+) -> Optional[str]:
+    """Cache a catalog revision's thread mbox as a git blob.
+
+    The freshest fetch wins, unconditionally: this column holds "the
+    thread as it last looked", which is what a poll counted and what
+    :func:`b4.review._prev_thread_msgids` diffs the next fetch against, so
+    an older snapshot is never the better answer.  Nothing arbitrates here
+    because nothing has to -- a thread that does not hold the whole series
+    is cached as a *series* separately, by
+    :func:`store_revision_series_blob`.
+
+    Unlike _store_thread_blob this records the SHA in the revisions
+    catalog only -- the review branch tracking ref belongs to the
+    tracked revision.
+    """
+    return _store_revision_blob(
+        conn, topdir, change_id, revision, msgs, set_revision_thread_blob, 'thread'
+    )
+
+
+def store_revision_series_blob(
+    conn: sqlite3.Connection,
+    topdir: str,
+    change_id: str,
+    revision: int,
+    msgs: List[Any],
+) -> Optional[str]:
+    """Cache a catalog revision's stitched series mbox as a git blob.
+
+    What a range-diff reassembled with the get_extra_series() passes,
+    which is the only way to get every patch of a version posted with
+    broken threading.  Recording it is what keeps the next range-diff from
+    paying for those passes again; :func:`set_revision_thread_blob` drops
+    it when the underlying thread changes.
+    """
+    return _store_revision_blob(
+        conn, topdir, change_id, revision, msgs, set_revision_series_blob, 'series'
+    )
+
+
+def _store_revision_blob(
+    conn: sqlite3.Connection,
+    topdir: str,
+    change_id: str,
+    revision: int,
+    msgs: List[Any],
+    setter: Callable[[sqlite3.Connection, str, int, str], bool],
+    what: str,
+) -> Optional[str]:
+    """Write *msgs* as a git blob and record its SHA on the catalog row."""
+    blob_sha = _write_mbox_blob(topdir, msgs)
+    if blob_sha is None:
+        logger.debug('Could not store %s blob for %s v%d', what, change_id, revision)
+        return None
+    if not setter(conn, change_id, revision, blob_sha):
+        logger.debug('No catalog row for %s v%d, not caching', change_id, revision)
+        return None
+    return blob_sha
+
+
 def _store_thread_blob(topdir: str, change_id: str, msgs: List[Any]) -> Optional[str]:
     """Serialize msgs to mboxrd and write as a git blob; update tracking commit.
 
@@ -2344,24 +2970,12 @@ def _store_thread_blob(topdir: str, change_id: str, msgs: List[Any]) -> Optional
     """
     # Local import first — avoids circular deps AND prevents UnboundLocalError
     # that would occur if `import b4.review` appeared after a `b4.xxx` call.
-    import io
-
     import b4.review as _b4_review
 
-    buf = io.BytesIO()
-    b4.save_mboxrd_mbox(msgs, buf)
-    mbox_bytes = buf.getvalue()
-    if not mbox_bytes:
-        logger.debug('No bytes to store for thread blob for %s', change_id)
-        return None
-
-    ecode, out = b4.git_run_command(
-        topdir, ['hash-object', '-w', '--stdin'], stdin=mbox_bytes
-    )
-    if ecode != 0:
+    blob_sha = _write_mbox_blob(topdir, msgs)
+    if blob_sha is None:
         logger.debug('Could not write thread blob for %s', change_id)
         return None
-    blob_sha = out.strip()
 
     branch_name = f'b4/review/{change_id}'
     if b4.git_branch_exists(topdir, branch_name):
@@ -2707,13 +3321,480 @@ def ensure_thread_context_blob(
     return ctx_sha
 
 
+def _member_patch_count(conn: sqlite3.Connection, change_id: str, revision: int) -> int:
+    """How many member patches a rethreaded revision was stitched from.
+
+    Both the poll budget and the fetch path key off this number -- one
+    counts the round-trips it will cost, the other decides whether there
+    is a series to reassemble at all -- so they read it from here rather
+    than each re-deriving "position > 0" from the patch rows.
+    """
+    patches = get_series_patches(conn, change_id, revision)
+    return sum(1 for p in patches if p.get('position', 0) > 0)
+
+
+def _revision_poll_cost(
+    conn: sqlite3.Connection, change_id: str, rev: Dict[str, Any]
+) -> int:
+    """Lore round-trips polling this revision will cost.
+
+    One for a plain revision, one per member patch for a rethreaded one --
+    :func:`_fetch_revision_thread_msgs` reassembles those from their
+    per-patch message-ids.
+    """
+    if not rev.get('is_rethreaded'):
+        return 1
+    return max(1, _member_patch_count(conn, change_id, int(rev['revision'])))
+
+
+def _fetch_revision_thread_msgs(
+    identifier: str,
+    conn: sqlite3.Connection,
+    change_id: str,
+    rev: Dict[str, Any],
+) -> Optional[List[Any]]:
+    """Fetch the full thread for a catalog revision.
+
+    Rethreaded revisions reassemble from their per-patch message-ids;
+    plain revisions fetch the single thread mbox.  Returns None on
+    failure or when offline.
+
+    The plain arm does not go through
+    :func:`b4.review.retrieve_series_messages`, which fetches the same
+    thread with the same ``nocache``: a poll runs unattended, so it wants
+    the quiet fetch that reports a miss by returning None rather than the
+    interactive one that logs a lookup per revision and raises.
+    """
+    # Checked up front so the rethreaded path below does not fire off one
+    # doomed request per member patch while offline.
+    if not b4.can_network:
+        return None
+    revision = int(rev['revision'])
+    if rev.get('is_rethreaded'):
+        if _member_patch_count(conn, change_id, revision) >= 2:
+            import b4.review as _b4_review
+
+            series_dict = {
+                'message_id': rev.get('message_id', ''),
+                'change_id': change_id,
+                'revision': revision,
+                'is_rethreaded': True,
+            }
+            try:
+                return _b4_review.retrieve_series_messages(series_dict, identifier)
+            except liblore.OperationCancelledError:
+                raise
+            except Exception as ex:
+                logger.debug(
+                    'Could not reassemble rethreaded v%d of %s: %s',
+                    revision,
+                    change_id,
+                    ex,
+                )
+                return None
+    message_id = str(rev.get('message_id') or '')
+    if not message_id:
+        return None
+    return _fetch_thread_msgs(message_id)
+
+
+def _tracked_revisions(conn: sqlite3.Connection, change_id: str) -> Set[int]:
+    """Revisions of *change_id* that a live series row currently tracks.
+
+    The series machinery fetches and counts these on the same sweep, so
+    polling them again would pay twice for one answer -- and lose: the
+    poller counts a revision it has no row for as a first fetch, which
+    initialises ``seen_message_count`` to the total and takes the unread
+    badge off mail the maintainer has not read.
+    """
+    return {
+        int(row[0])
+        for row in conn.execute(
+            'SELECT revision FROM series WHERE change_id = ?'
+            " AND COALESCE(status, 'new') != 'archived'",
+            (change_id,),
+        )
+    }
+
+
+def _thread_blob_exists(topdir: str, blob_sha: str) -> bool:
+    """Whether a recorded thread blob is still in the object store.
+
+    Thread blobs are written with ``hash-object -w`` and referenced only
+    from the database, so any ``git gc`` may prune one while the SHA stays
+    recorded -- a pruned blob reads the same as an absent one.  ``cat-file
+    -e`` resolves the object and exits, transferring none of its contents.
+    """
+    ecode, _ = b4.git_run_command(topdir, ['cat-file', '-e', blob_sha], decode=False)
+    return ecode == 0
+
+
+def _ensure_thread_blob(
+    conn: sqlite3.Connection,
+    topdir: Optional[str],
+    change_id: str,
+    revision: int,
+    blob_sha: Optional[str],
+    msgs: List[Any],
+) -> None:
+    """Cache *msgs* for a revision only if no stored blob survives.
+
+    The quiet-poll counterpart of :func:`store_revision_thread_blob`, for
+    the case where the fetch returned the same count as last sweep: the
+    stored blob holds that same thread, so rewriting it would serialize an
+    mbox and re-record a SHA the row already carries -- and, worse, throw
+    away a stitched ``series_blob`` that is still perfectly good.  The one
+    thing left to check is whether ``git gc`` has since pruned it, which
+    ``cat-file -e`` answers without transferring the mbox.
+    """
+    if not topdir:
+        return
+    if blob_sha and _thread_blob_exists(topdir, blob_sha):
+        return
+    store_revision_thread_blob(conn, topdir, change_id, revision, msgs)
+
+
+def _update_one_revision_count(
+    identifier: str,
+    conn: sqlite3.Connection,
+    topdir: Optional[str],
+    change_id: str,
+    rev: Dict[str, Any],
+    now: str,
+) -> Optional[int]:
+    """Update message counts for a single non-tracked catalog revision.
+
+    Fetches the thread and counts it, exactly as the tracked revision's
+    counts are maintained.  public-inbox offers no cheaper way to ask
+    "did anything arrive?": its only date-range search runs against the
+    whole inbox, so a probe costs more than the thread it is probing.
+
+    Returns the change in ``message_count`` -- 0 when nothing moved,
+    negative for a recorded shrink, the full total on a first count -- or
+    None on fetch failure.  The direction matters to the caller: a shrink
+    is an update but not new mail.  A quiet revision still writes its
+    check timestamp -- the rotation depends on it -- so unlike the
+    tracked-revision writers this does not leave the DB mtime alone.
+
+    *rev* is the revision's own catalog row (`_REVISION_SELECT` reads the
+    catalog and nothing else), so its counts are exactly what the writes
+    below compare against.
+    """
+    revision = int(rev['revision'])
+    old_count, old_seen = rev.get('message_count'), rev.get('seen_message_count')
+
+    msgs = _fetch_revision_thread_msgs(identifier, conn, change_id, rev)
+    if not msgs:
+        # Record the attempt, or a revision that always fails keeps
+        # sorting to the front of the rotation and starves the rest.
+        # Offline nothing was attempted, so nothing is recorded.
+        if b4.can_network:
+            _stamp_check(conn, change_id, revision, now)
+            conn.commit()
+        return None
+    count = len(msgs)
+
+    if old_count is not None and count == old_count:
+        # Nothing arrived, so the counts stay put -- but the check itself
+        # is recorded, or the rotation never advances past a quiet
+        # revision and the column would mean "last changed" instead.
+        _stamp_check(conn, change_id, revision, now)
+        conn.commit()
+        # A settled old version never changes count, so this is its only
+        # chance at a cached thread; without it every range-diff against
+        # that version refetches from lore.
+        _ensure_thread_blob(
+            conn, topdir, change_id, revision, rev.get('thread_blob'), msgs
+        )
+        return 0
+
+    verdict = _counts_after_fetch(old_count, old_seen, count)
+    if verdict is None:
+        # Only an equal total yields None, and that returned above.
+        return 0
+
+    new_count, new_seen = verdict
+    last_activity = _latest_date_from_msgs(msgs)
+    _write_counts(conn, change_id, revision, new_count, new_seen, now)
+    _touch_last_mail(conn, change_id, revision, last_activity)
+    conn.commit()
+    if topdir:
+        store_revision_thread_blob(conn, topdir, change_id, revision, msgs)
+    return new_count - (old_count or 0)
+
+
+# Minimum age of a revision's last check before the poller fetches it
+# again, keyed by how recently its thread saw mail.  Below the poll cap
+# the LRU rotation never engages, so without a floor every sweep
+# re-downloads every quiet old version's full thread just to re-learn it
+# is quiet; these keep late-reply detection at bounded staleness for
+# near-zero steady-state cost.
+_POLL_MIN_INTERVALS: Tuple[Tuple[Optional[float], float], ...] = (
+    (7 * 86400.0, 3600.0),  # mail this week: hourly
+    (30 * 86400.0, 6 * 3600.0),  # this month: six-hourly
+    (None, 24 * 3600.0),  # older or unknown: daily
+)
+
+
+def _poll_due(rev: Dict[str, Any], now: str) -> bool:
+    """Whether enough time has passed to re-poll a revision.
+
+    A never-checked revision is always due, and so is one whose stamps do
+    not parse -- when in doubt, poll.  Failed fetches stamp the check time
+    too, so a dead message-id is retried on this same schedule instead of
+    on every sweep.
+    """
+    checked = rev.get('last_update_check')
+    if not checked:
+        return True
+    try:
+        now_dt = datetime.datetime.fromisoformat(now)
+        age = (now_dt - datetime.datetime.fromisoformat(str(checked))).total_seconds()
+        quiet: Optional[float] = None
+        if rev.get('last_mail_at'):
+            quiet = (
+                now_dt - datetime.datetime.fromisoformat(str(rev['last_mail_at']))
+            ).total_seconds()
+    except (ValueError, TypeError):
+        return True
+    for horizon, interval in _POLL_MIN_INTERVALS:
+        if horizon is None or (quiet is not None and quiet <= horizon):
+            return age >= interval
+    return True
+
+
+def update_revision_message_counts(
+    identifier: str,
+    series_list: List[Dict[str, Any]],
+    topdir: Optional[str] = None,
+    max_revisions_per_series: Optional[int] = None,
+    cancel_cb: Optional[Callable[[], bool]] = None,
+    only_revisions: Optional[Set[int]] = None,
+    status_cb: Optional[Callable[[str], None]] = None,
+) -> Dict[str, int]:
+    """Fetch and store thread message counts for non-tracked revisions.
+
+    The series machinery owns the tracked revision's counts; this covers
+    every *other* revision in the catalog so new mail landing on an old
+    version's thread is still noticed.
+
+    Each polled revision has its thread fetched and counted.  Only the
+    check timestamp is written when the count has not moved, so an
+    unread badge never flickers on a quiet sweep; a count that has moved
+    is stored, and when *topdir* is given the mbox is cached as a git
+    blob.  A revision counted for the first time starts with
+    ``seen_message_count`` equal to the total, so no badge appears for
+    mail that predates it being tracked.
+
+    Revisions are polled least-recently-attempted first, capped by
+    *max_revisions_per_series* — a lore round-trip per revision (and, for
+    a rethreaded one, per member patch) adds up fast across a large
+    tracking list, so each sweep takes the next few in the rotation and
+    every version comes back around.  Failed fetches do not spend the
+    budget, so one unreachable revision cannot starve the live ones behind
+    it -- except under *only_revisions*, where the consecutive-failure stop
+    is lifted and the cap is all that bounds the run.  Below the cap the
+    rotation alone would re-fetch everything every sweep, so a revision
+    checked recently enough is skipped outright -- see :func:`_poll_due`
+    for the schedule.
+
+    *cancel_cb* is polled between revisions so a cancelled sweep stops
+    here rather than grinding through the rest of the catalog.
+    *only_revisions* narrows the poll to named versions, for a caller
+    that knows which ones it wants counted; naming them also bypasses the
+    minimum-age skip, since the caller is asking now.  *status_cb*, when
+    given, is handed each polled series' subject, so a caller driving a
+    progress display has something to show during what is otherwise
+    minutes of silent lore traffic.  A subject and nothing else: how far
+    along a sweep is belongs to the sweep, which knows how many series it
+    handed over and has already drawn a bar for them.
+
+    Returns ``{'updated': n, 'new_mail': n, 'errors': n,
+    'fresh_errors': n, 'polled': n, 'cancelled': 0-or-1}``.  *cancelled*
+    reports that *cancel_cb* stopped the sweep partway, so a caller does
+    not present a partial run as a complete one.  *updated* counts revisions whose
+    counts actually changed; *new_mail* is the subset that genuinely grew,
+    first fetches and recorded shrinks excluded -- a first fetch changes
+    the row without anything having arrived, and reporting it as new mail
+    would make the first sweep after a catalog grows claim activity on
+    every version of every series.  *polled* counts revisions whose
+    thread came back at all, which is what separates "one message-id will
+    not fetch" from "the poller is not working".  *fresh_errors* counts
+    the failures excluding revisions already known dead (attempted before,
+    never fetched once): a permanently dead message-id is worth one
+    report, not one per sweep.
+    """
+    updated = 0
+    new_mail = 0
+    errors = 0
+    fresh_errors = 0
+    polled_total = 0
+    cancelled = False
+    # The same set update_all_tracking() drops, no wider: a late reply lands
+    # on an old version of an applied series just as readily as on its
+    # tracked one, which is polled past 'accepted'/'thanked' for that reason.
+    skip_statuses = frozenset(('archived', 'snoozed'))
+
+    try:
+        conn = get_db(identifier)
+    except FileNotFoundError:
+        return {
+            'updated': 0,
+            'new_mail': 0,
+            'errors': 0,
+            'fresh_errors': 0,
+            'polled': 0,
+            'cancelled': 0,
+        }
+
+    try:
+        for series in series_list:
+            if cancel_cb is not None and cancel_cb():
+                # Reported, like the per-revision check below: a cancel
+                # landing between series otherwise presents the partial
+                # sweep as a clean, complete run.
+                cancelled = True
+                break
+            if series.get('status') in skip_statuses:
+                continue
+            change_id = series.get('change_id', '')
+            if not change_id:
+                continue
+            tracked_rev = int(series.get('revision') or 1)
+
+            # Backstop for a series row written before every path that
+            # points one at a revision catalogued it.  An INSERT OR IGNORE
+            # that ignores changes no page, so committing it leaves the
+            # file -- and the mtime the TUI reloads on -- untouched.
+            _ensure_catalog_row(conn, change_id, tracked_rev)
+            conn.commit()
+
+            # Every live series row's revision is off limits, not just the
+            # one this call was handed: rescan_branches can leave a
+            # change_id with more than one, and polling the revision another
+            # row tracks would overwrite its unread state from a first fetch.
+            tracked_revs = _tracked_revisions(conn, change_id) | {tracked_rev}
+            candidates = [
+                rev
+                for rev in get_revisions(conn, change_id)
+                if int(rev['revision']) not in tracked_revs
+                and (only_revisions is None or int(rev['revision']) in only_revisions)
+            ]
+            # Least-recently-attempted first: never-tried revisions have no
+            # stamp and drain first, then rejoin the rotation.  Ordering on
+            # the count would pin the cap to whichever revisions keep
+            # failing and never come back to the older versions.
+            candidates.sort(
+                key=lambda rev: (
+                    rev.get('last_update_check') or '',
+                    -int(rev['revision']),
+                )
+            )
+
+            if candidates and status_cb is not None:
+                status_cb(str(series.get('subject') or ''))
+
+            polled = 0
+            consecutive_errors = 0
+            for rev in candidates:
+                if (
+                    max_revisions_per_series is not None
+                    and polled >= max_revisions_per_series
+                ):
+                    break
+                if cancel_cb is not None and cancel_cb():
+                    # Reported, not just broken out of: a cancel during the
+                    # last series' poll otherwise ends the sweep by falling
+                    # off the loop, and it reports a clean run.
+                    cancelled = True
+                    break
+                now = datetime.datetime.now(datetime.timezone.utc).isoformat()
+                if only_revisions is None and not _poll_due(rev, now):
+                    continue
+                first_fetch = rev.get('message_count') is None
+                try:
+                    delta = _update_one_revision_count(
+                        identifier, conn, topdir, change_id, rev, now
+                    )
+                except liblore.OperationCancelledError:
+                    # Every revision counted so far committed its own row, so
+                    # the badges are already on screen.  Letting this out
+                    # would discard the tally that explains them, and a
+                    # poller that never works would read exactly like a
+                    # cancelled one.
+                    cancelled = True
+                    break
+                if delta is None:
+                    # Offline is not a failure: nothing was requested, and
+                    # counting it would mail the maintainer about every
+                    # revision of every series.
+                    if b4.can_network:
+                        errors += 1
+                        # A revision that was attempted before and has never
+                        # fetched once is known dead; only failures outside
+                        # that set are news.
+                        if not (first_fetch and rev.get('last_update_check')):
+                            fresh_errors += 1
+                        if only_revisions is not None:
+                            # Charged here and only here.  A caller that
+                            # named its revisions has the two-in-a-row stop
+                            # below lifted -- dead message-ids are exactly
+                            # what a backward search turns up -- so the cap
+                            # is the only thing left bounding the run, and a
+                            # failed fetch made the same round-trip a
+                            # successful one does.  Without this, [o] on a
+                            # v20 series whose ids all 404 answers with 19
+                            # of them, which is the number its own call site
+                            # says the cap prevents.  In the sweep the stop
+                            # still fires, so failures stay free there and
+                            # one dead revision cannot starve the live ones
+                            # behind it.
+                            polled += _revision_poll_cost(conn, change_id, rev)
+                    consecutive_errors += 1
+                    # Two in a row means offline rather than one bad
+                    # message-id -- but not when the caller named the
+                    # revisions it wants, since dead message-ids are exactly
+                    # what a backward lore search turns up.
+                    if consecutive_errors >= 2 and only_revisions is None:
+                        break
+                    continue
+                consecutive_errors = 0
+                # Charged in round-trips, which is what the budget exists to
+                # cap: a rethreaded revision costs one per member patch, so
+                # spending a single unit on it lets a handful of them issue
+                # dozens of requests inside a cap of three.
+                polled += _revision_poll_cost(conn, change_id, rev)
+                polled_total += 1
+                if delta:
+                    updated += 1
+                    if delta > 0 and not first_fetch:
+                        new_mail += 1
+            if cancelled:
+                break
+    finally:
+        conn.close()
+    return {
+        'updated': updated,
+        'new_mail': new_mail,
+        'errors': errors,
+        'fresh_errors': fresh_errors,
+        'polled': polled_total,
+        'cancelled': int(cancelled),
+    }
+
+
 def mark_all_messages_seen(
     conn: sqlite3.Connection, change_id: str, revision: int
 ) -> None:
-    """Set seen_message_count = message_count, clearing the unread badge."""
+    """Set seen_message_count = message_count, clearing the unread badge.
+
+    One row, one write.  The badge is derived from this revision's catalog
+    row, so clearing it is that row's own total -- no second copy to keep in
+    step, and no clamping one table's seen count against the other's total.
+    """
     conn.execute(
-        'UPDATE series SET seen_message_count = message_count'
-        ' WHERE change_id = ? AND revision = ?',
+        'UPDATE revisions SET seen_message_count = message_count'
+        ' WHERE change_id = ? AND revision = ? AND message_count IS NOT NULL',
         (change_id, revision),
     )
     conn.commit()
@@ -2724,8 +3805,12 @@ def sync_seen_from_unseen_count(
 ) -> bool:
     """Sync seen_message_count so the unread badge matches the messages DB.
 
-    Sets ``seen_message_count = message_count - unseen_count``, clamped
-    to [0, message_count].  Only writes when the value actually changes.
+    Sets ``seen_message_count = message_count - unseen_count``, clamped to
+    [0, message_count].  Only writes when the value actually changes, so a
+    sync that agrees with the stored state leaves the DB mtime alone.
+
+    Applies to the revision's catalog row whether or not a series currently
+    tracks it: that row is where the badge is read from either way.
 
     Returns True if the database was updated, False otherwise.
     """
@@ -2733,33 +3818,27 @@ def sync_seen_from_unseen_count(
         conn = get_db(identifier)
     except FileNotFoundError:
         return False
-
-    row = conn.execute(
-        'SELECT message_count, seen_message_count FROM series'
-        ' WHERE change_id = ? AND revision = ?',
-        (change_id, revision),
-    ).fetchone()
-    if row is None:
-        conn.close()
-        return False
-
-    fc = row['message_count']
-    if fc is None:
-        conn.close()
-        return False
-
-    new_seen = max(0, min(fc, fc - unseen_count))
-    if new_seen == row['seen_message_count']:
+    try:
+        row = conn.execute(
+            'SELECT message_count, seen_message_count FROM revisions'
+            ' WHERE change_id = ? AND revision = ?',
+            (change_id, revision),
+        ).fetchone()
+        if row is None or row['message_count'] is None:
+            return False
+        total = row['message_count']
+        new_seen = max(0, min(total, total - unseen_count))
+        if new_seen == row['seen_message_count']:
+            return False
+        conn.execute(
+            'UPDATE revisions SET seen_message_count = ?'
+            ' WHERE change_id = ? AND revision = ?',
+            (new_seen, change_id, revision),
+        )
+        conn.commit()
+        return True
+    finally:
         conn.close()
-        return False
-
-    conn.execute(
-        'UPDATE series SET seen_message_count = ? WHERE change_id = ? AND revision = ?',
-        (new_seen, change_id, revision),
-    )
-    conn.commit()
-    conn.close()
-    return True
 
 
 def refresh_message_count(
@@ -2772,13 +3851,14 @@ def refresh_message_count(
     taking/accepting a series).
 
     Only ``message_count`` and ``last_update_check`` are updated;
-    ``seen_message_count`` is left unchanged so the unread badge
-    continues to reflect the actual read state from the messages DB.
-    When ``message_count`` was NULL (first fetch), ``seen_message_count``
-    is initialised to the same value (no badge) as a safe default.
+    ``seen_message_count`` is left unchanged so the unread badge continues
+    to reflect the actual read state from the messages DB.  When
+    ``message_count`` was NULL (first fetch), ``seen_message_count`` is
+    initialised to the same value (no badge) as a safe default.
 
-    Only writes to the database when the count differs from the stored
-    value, keeping the DB mtime stable when nothing changed.
+    Only writes when the count differs from the stored value, keeping the
+    DB mtime stable when nothing changed.  Applies to the revision's
+    catalog row whether or not a series currently tracks it.
 
     Returns True if the database was updated, False otherwise.
     """
@@ -2787,53 +3867,20 @@ def refresh_message_count(
         conn = get_db(identifier)
     except FileNotFoundError:
         return False
-
-    row = conn.execute(
-        'SELECT message_count, seen_message_count FROM series'
-        ' WHERE change_id = ? AND revision = ?',
-        (change_id, revision),
-    ).fetchone()
-    if row is None:
-        conn.close()
-        return False
-
-    count = total_messages
-    old_count = row['message_count']
-
-    if old_count is not None and count == old_count:
-        # Nothing changed — skip the write to keep the DB mtime stable.
+    try:
+        _ensure_catalog_row(conn, change_id, revision)
+        stored = _read_counts(conn, change_id, revision)
+        if stored is None:
+            return False
+        verdict = _counts_after_fetch(stored[0], stored[1], total_messages)
+        if verdict is None:
+            # Unchanged total -- skip the write so the DB mtime stays put.
+            return False
+        _write_counts(conn, change_id, revision, verdict[0], verdict[1], now)
+        conn.commit()
+        return True
+    finally:
         conn.close()
-        return False
-
-    if old_count is None:
-        # First fetch: initialise both counts equally (no badge).
-        conn.execute(
-            'UPDATE series SET message_count = ?, seen_message_count = ?,'
-            '  last_update_check = ?'
-            ' WHERE change_id = ? AND revision = ?',
-            (count, count, now, change_id, revision),
-        )
-    else:
-        # Count changed: update only message_count; cap seen if it
-        # exceeds the new count (possible when dedup reduces the total).
-        seen = row['seen_message_count']
-        if seen is not None and seen > count:
-            conn.execute(
-                'UPDATE series SET message_count = ?, seen_message_count = ?,'
-                '  last_update_check = ?'
-                ' WHERE change_id = ? AND revision = ?',
-                (count, count, now, change_id, revision),
-            )
-        else:
-            conn.execute(
-                'UPDATE series SET message_count = ?, last_update_check = ?'
-                ' WHERE change_id = ? AND revision = ?',
-                (count, now, change_id, revision),
-            )
-
-    conn.commit()
-    conn.close()
-    return True
 
 
 def rescan_branches(
diff --git a/src/b4/review_tui/_tracking_app.py b/src/b4/review_tui/_tracking_app.py
index 11e79ef2..42f0f46c 100644
--- a/src/b4/review_tui/_tracking_app.py
+++ b/src/b4/review_tui/_tracking_app.py
@@ -4263,22 +4263,33 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
             prior_thread_blob = old_series.get('thread-context-blob', '')
             prior_msgid = old_series.get('header-info', {}).get('msgid', '')
 
-            # --- 1c. Record the current rev's mbox blob in the DB ---
-            # Do this before archiving so the blob SHA survives for range-diff.
-            # The blob may later be GC'd; callers must tolerate a missing blob.
-            if self._identifier:
-                cur_mbox_blob = old_series.get('thread-blob', '')
-                if cur_mbox_blob:
+            # --- 1c. Record the outgoing rev's mbox blob in the catalog ---
+            # Before anything fallible: every abort return below would skip
+            # this write, and the blob -- written with `hash-object -w` and
+            # referenced only from the database -- costs nothing to record
+            # now while the tracking commit is still the branch's.  The
+            # tracked revision's catalog row is guaranteed by
+            # add_series_to_db/_ensure_catalog_row and the v11 backfill; a
+            # row somehow missing degrades to a debug line and a later
+            # lore refetch.
+            cur_mbox_blob = old_series.get('thread-blob', '')
+            if self._identifier and cur_mbox_blob:
+                try:
+                    _conn = b4.review.tracking.get_db(self._identifier)
                     try:
-                        _conn = b4.review.tracking.get_db(self._identifier)
-                        b4.review.tracking.set_revision_thread_blob(
+                        if not b4.review.tracking.set_revision_thread_blob(
                             _conn, change_id, current_rev, cur_mbox_blob
-                        )
+                        ):
+                            logger.debug(
+                                'No catalog row for v%d, thread blob not recorded',
+                                current_rev,
+                            )
+                    finally:
                         _conn.close()
-                    except Exception as _ex:
-                        logger.debug(
-                            'Could not record thread blob for v%d: %s', current_rev, _ex
-                        )
+                except Exception as _ex:
+                    logger.debug(
+                        'Could not record thread blob for v%d: %s', current_rev, _ex
+                    )
 
             # --- 2. Resolve metadata for git-am ---
             top_msgid = None
diff --git a/src/tests/test_review_tracking.py b/src/tests/test_review_tracking.py
index 6394d332..f65bbb4d 100644
--- a/src/tests/test_review_tracking.py
+++ b/src/tests/test_review_tracking.py
@@ -1596,15 +1596,28 @@ class TestFollowupCounts:
     def test_schema_has_followup_columns(
         self, tmp_path: pytest.TempPathFactory
     ) -> None:
-        """Verify fresh DB has message_count, seen_message_count, last_update_check, last_activity_at."""
+        """Read state is the catalog's; `series` keeps only its own stamp."""
         conn = review_tracking.init_db('fc-schema-test')
-        cursor = conn.execute('PRAGMA table_info(series)')
-        col_names = {row[1] for row in cursor.fetchall()}
-        assert 'message_count' in col_names
-        assert 'seen_message_count' in col_names
-        assert 'last_update_check' in col_names
-        assert 'last_activity_at' in col_names
+        series_cols = {row[1] for row in conn.execute('PRAGMA table_info(series)')}
+        rev_cols = {row[1] for row in conn.execute('PRAGMA table_info(revisions)')}
         conn.close()
+        assert {
+            'message_count',
+            'seen_message_count',
+            'last_update_check',
+        } <= rev_cols
+        # One owner: no second copy on `series` for a reader to prefer.
+        assert (
+            not {
+                'message_count',
+                'seen_message_count',
+                'last_update_check',
+            }
+            & series_cols
+        )
+        # This one is the series' own maintainer-action stamp, not the
+        # catalog's last_mail_at, and it stays.
+        assert 'last_activity_at' in series_cols
 
     def test_migration_adds_followup_columns(
         self, tmp_path: pytest.TempPathFactory
@@ -1661,7 +1674,7 @@ class TestFollowupCounts:
         )
         # Manually set a delta
         conn.execute(
-            'UPDATE series SET message_count = 10, seen_message_count = 6'
+            'UPDATE revisions SET message_count = 10, seen_message_count = 6'
             ' WHERE change_id = ?',
             ('fc-seen',),
         )
@@ -1673,7 +1686,7 @@ class TestFollowupCounts:
         # Reopen with get_db to get row_factory for named column access
         conn = review_tracking.get_db('fc-seen-test')
         row = conn.execute(
-            'SELECT message_count, seen_message_count FROM series WHERE change_id = ?',
+            'SELECT message_count, seen_message_count FROM revisions WHERE change_id = ?',
             ('fc-seen',),
         ).fetchone()
         assert row['message_count'] == 10
@@ -2813,7 +2826,7 @@ class TestUpdateSeriesTrackingCounts:
         review_tracking.update_series_status(conn, change_id, 'accepted')
         # Baseline from the reviewing days: 5 messages, all seen
         conn.execute(
-            'UPDATE series SET message_count = 5, seen_message_count = 5'
+            'UPDATE revisions SET message_count = 5, seen_message_count = 5'
             ' WHERE change_id = ?',
             (change_id,),
         )
@@ -2854,7 +2867,7 @@ class TestUpdateSeriesTrackingCounts:
 
         conn = review_tracking.get_db(identifier)
         row = conn.execute(
-            'SELECT message_count, seen_message_count FROM series WHERE change_id = ?',
+            'SELECT message_count, seen_message_count FROM revisions WHERE change_id = ?',
             (change_id,),
         ).fetchone()
         conn.close()
@@ -4580,7 +4593,7 @@ class TestUpdateMessageCountSeenBump:
     @staticmethod
     def _get_counts(conn: sqlite3.Connection) -> tuple[int, int]:
         row = conn.execute(
-            'SELECT message_count, seen_message_count FROM series'
+            'SELECT message_count, seen_message_count FROM revisions'
             ' WHERE change_id = ? AND revision = ?',
             ('bump-cid', 1),
         ).fetchone()
diff --git a/src/tests/test_tui_tracking.py b/src/tests/test_tui_tracking.py
index 664956ec..cc80e7b1 100644
--- a/src/tests/test_tui_tracking.py
+++ b/src/tests/test_tui_tracking.py
@@ -108,7 +108,7 @@ def _seed_db(identifier: str, series_list: List[Dict[str, Any]]) -> None:
         mc = s.get('message_count')
         if mc is not None:
             conn.execute(
-                'UPDATE series SET message_count = ?, seen_message_count = ? '
+                'UPDATE revisions SET message_count = ?, seen_message_count = ? '
                 'WHERE change_id = ? AND revision = ?',
                 (
                     mc,
@@ -965,7 +965,7 @@ class TestTrackingWithReviewBranch:
             # Verify message counts are equal in DB
             conn = tracking.get_db(identifier)
             cursor = conn.execute(
-                'SELECT message_count, seen_message_count FROM series WHERE change_id = ?',
+                'SELECT message_count, seen_message_count FROM revisions WHERE change_id = ?',
                 (change_id,),
             )
             row = cursor.fetchone()
@@ -1199,7 +1199,7 @@ class TestTrackingUpgradeNewSeries:
         )
         # Set message counts so we can verify they get reset
         conn.execute(
-            'UPDATE series SET message_count = 6, seen_message_count = 4'
+            'UPDATE revisions SET message_count = 6, seen_message_count = 4'
             ' WHERE change_id = ?',
             (change_id,),
         )
@@ -1230,18 +1230,22 @@ class TestTrackingUpgradeNewSeries:
             # Verify the DB was updated to v13 with counts reset
             conn = tracking.get_db(identifier)
             cursor = conn.execute(
-                'SELECT revision, message_id, message_count,'
-                ' seen_message_count FROM series'
-                ' WHERE change_id = ?',
+                'SELECT revision, message_id FROM series WHERE change_id = ?',
                 (change_id,),
             )
             row = cursor.fetchone()
+            # Counts are not on this row to reset: the series now points at
+            # v13's catalog entry, which starts out uncounted.
+            counts = conn.execute(
+                'SELECT message_count, seen_message_count FROM revisions'
+                ' WHERE change_id = ? AND revision = 13',
+                (change_id,),
+            ).fetchone()
             conn.close()
             assert row is not None
             assert row[0] == 13
             assert row[1] == 'v13@ex.com'
-            assert row[2] is None  # message_count reset
-            assert row[3] is None  # seen_message_count reset
+            assert counts is None or (counts[0] is None and counts[1] is None)
 
 
 class TestTrackingSnooze:

-- 
2.53.0


^ permalink raw reply related	[flat|nested] 26+ messages in thread

* [PATCH RFC v2 10/25] review: give per-change_id state its own table
  2026-08-12 21:46 [PATCH RFC v2 00/25] review: track and browse every version of a tracked series Christian Brauner
                   ` (8 preceding siblings ...)
  2026-08-12 21:46 ` [PATCH RFC v2 09/25] review: track message counts for all revisions of a series Christian Brauner
@ 2026-08-12 21:46 ` Christian Brauner
  2026-08-12 21:46 ` [PATCH RFC v2 11/25] review: test per-revision message tracking Christian Brauner
                   ` (14 subsequent siblings)
  24 siblings, 0 replies; 26+ messages in thread
From: Christian Brauner @ 2026-08-12 21:46 UTC (permalink / raw)
  To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Christian Brauner (Amutable)

series.branch_sha records the HEAD of b4/review/<change_id> so a rescan
can skip a branch that has not moved.  There is one such branch per
change_id, but the column lives on a table keyed (change_id, revision),
so the fact is stored once per version of the series.

That forces an arbitration nothing should have to make.  rescan_branches()
reads it back with ORDER BY revision DESC LIMIT 1, believing the highest
revision's copy, while writing only the row whose revision the branch's
tracking commit happens to name.  A change_id with more than one live
row, which rescan_branches() itself can produce, then has a stale copy
sitting where the reader looks.

Give it a table of its own, keyed by change_id alone.  The reader stops
choosing, the writer stops picking a row, and delete_series() drops the
entry with the rest of the change.  The migration seeds each change_id
from the same row the old reader believed, so nothing is reinterpreted on
the way across.  A wrong sha costs one extra tracking-commit read on the
next rescan, since this is a cache key rather than data.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 src/b4/review/tracking.py         | 141 +++++++++++++++++++++++++++++++-------
 src/tests/test_review_tracking.py |   3 +-
 2 files changed, 120 insertions(+), 24 deletions(-)

diff --git a/src/b4/review/tracking.py b/src/b4/review/tracking.py
index 7d42b160..68701238 100644
--- a/src/b4/review/tracking.py
+++ b/src/b4/review/tracking.py
@@ -26,7 +26,7 @@ logger = b4.logger
 REVIEW_METADATA_DIR = 'b4-review'
 REVIEW_METADATA_FILE = 'metadata.json'
 
-SCHEMA_VERSION = 11
+SCHEMA_VERSION = 12
 
 SERIES_PATCHES_DDL = """
 CREATE TABLE IF NOT EXISTS series_patches (
@@ -53,7 +53,6 @@ CREATE TABLE IF NOT EXISTS series (
     pw_series_id INTEGER,
     status TEXT DEFAULT 'new',
     fingerprint TEXT,
-    branch_sha TEXT,
     -- Per-revision read state (message_count, seen_message_count,
     -- last_update_check) lives on `revisions` and only there: a series row
     -- names which revision it tracks, and that revision's catalog row
@@ -73,6 +72,28 @@ CREATE TABLE IF NOT EXISTS series (
     UNIQUE (change_id, revision)
 )"""
 
+# Per-change_id state: one row per change_id, never one per revision.
+#
+# These facts belong to the change, not to a version of it -- the review
+# branch is `b4/review/<change_id>` and there is exactly one, the backward
+# search runs once per change_id -- so a copy on every `series` row left
+# each writer updating them all and each reader arbitrating between them
+# (MAX(back_searched), ORDER BY revision DESC LIMIT 1 for the other two).
+# That is the same "which copy wins" question the per-revision read state
+# moved onto `revisions` to stop asking.
+CHANGES_DDL = """
+CREATE TABLE IF NOT EXISTS changes (
+    change_id TEXT PRIMARY KEY,
+    -- HEAD of b4/review/<change_id> at the last branch rescan.
+    branch_sha TEXT,
+    -- "<branch-sha>:<catalog-sha1>" at the last successful known-revisions
+    -- mirror; see sync_revisions_catalog_to_branch.
+    catalog_synced TEXT,
+    -- 1 once the one-shot backward revision search has run for this
+    -- change_id, found something or not; see set_back_searched().
+    back_searched INTEGER DEFAULT 0
+)"""
+
 SCHEMA_SQL = (
     """
 CREATE TABLE IF NOT EXISTS schema_version (
@@ -117,6 +138,8 @@ CREATE INDEX IF NOT EXISTS idx_revisions_fingerprint ON revisions(fingerprint);
 CREATE INDEX IF NOT EXISTS idx_revisions_message_id ON revisions(message_id);
 
 """
+    + CHANGES_DDL
+    + ';'
     + SERIES_PATCHES_DDL
     + ';'
 )
@@ -164,6 +187,21 @@ def init_db(identifier: str) -> sqlite3.Connection:
     return conn
 
 
+def _drop_column(conn: sqlite3.Connection, table: str, col: str) -> None:
+    """Drop *col* from *table*, tolerating an sqlite too old to do it.
+
+    DROP COLUMN wants sqlite 3.35 (2021).  On anything older the column
+    simply stays, unread by everything above -- dead weight in the row, not
+    a correctness problem, and not worth a twelve-step table rebuild.
+
+    *table* and *col* are literals supplied by this module.
+    """
+    try:
+        conn.execute(f'ALTER TABLE {table} DROP COLUMN {col}')
+    except sqlite3.OperationalError as ex:
+        logger.debug('Could not drop %s.%s: %s', table, col, ex)
+
+
 def _migrate_db_if_needed(conn: sqlite3.Connection) -> None:
     """Apply any pending schema migrations in-place.
 
@@ -339,20 +377,12 @@ def _run_migrations(conn: sqlite3.Connection) -> None:
             # column left behind invites the next writer to keep it warm.
             # Inside the backfill guard on purpose: dropping a copy that
             # was never carried across would just lose it.
-            #
-            # DROP COLUMN wants sqlite 3.35 (2021).  On anything older the
-            # columns simply stay, unread by everything below -- dead
-            # weight in the row, not a correctness problem, and not worth
-            # a twelve-step table rebuild to reclaim.
             for col in (
                 'message_count',
                 'seen_message_count',
                 'last_update_check',
             ):
-                try:
-                    conn.execute(f'ALTER TABLE series DROP COLUMN {col}')
-                except sqlite3.OperationalError as ex:
-                    logger.debug('Could not drop series.%s: %s', col, ex)
+                _drop_column(conn, 'series', col)
         # Matching a posting by message-id became a hot lookup at v11 --
         # [l], [o] and the conflict check all run it -- and it was a full
         # table scan, unlike its fingerprint twin.
@@ -360,6 +390,27 @@ def _run_migrations(conn: sqlite3.Connection) -> None:
             'CREATE INDEX IF NOT EXISTS idx_revisions_message_id'
             ' ON revisions(message_id)'
         )
+    if version < 12:
+        # Per-change_id state gets its own table.  `branch_sha` describes
+        # the one b4/review/<change_id> branch, so a copy on every series
+        # row meant writing them all and reading back with ORDER BY
+        # revision DESC LIMIT 1 -- an arbitration between copies that only
+        # existed because the fact was stored per revision.
+        conn.execute(CHANGES_DDL)
+        series_cols = {row[1] for row in conn.execute('PRAGMA table_info(series)')}
+        if 'branch_sha' in series_cols:
+            # Highest revision wins, which is the row the old reader picked
+            # with ORDER BY revision DESC LIMIT 1.  A wrong sha here costs
+            # one extra tracking-commit read on the next rescan, so there is
+            # nothing to reconcile: it is a cache key, not data.
+            conn.execute(
+                'INSERT OR IGNORE INTO changes (change_id, branch_sha)'
+                ' SELECT change_id, ('
+                '  SELECT s.branch_sha FROM series s WHERE s.change_id ='
+                '  series.change_id ORDER BY s.revision DESC LIMIT 1)'
+                ' FROM series GROUP BY change_id'
+            )
+            _drop_column(conn, 'series', 'branch_sha')
     # Not an UPDATE: `version` is the primary key, so an UPDATE writes
     # nothing at all against an empty table -- and the read above maps "no
     # row" to version 0, so such a database would re-run the whole ladder
@@ -1453,6 +1504,52 @@ def record_known_revisions(
     conn.commit()
 
 
+def _set_change_state(conn: sqlite3.Connection, change_id: str, **cols: Any) -> None:
+    """Upsert per-change_id state, creating the `changes` row if needed.
+
+    A change_id acquires its row the first time something needs to
+    remember anything about it; nothing else has to keep the table in
+    step with `series`.
+
+    *cols* keys are column names supplied by the wrappers below, never
+    caller input.
+    """
+    assignments = ', '.join(f'{col} = ?' for col in cols)
+    conn.execute(
+        f'INSERT INTO changes (change_id, {", ".join(cols)})'
+        f' VALUES (?, {", ".join("?" for _ in cols)})'
+        f' ON CONFLICT (change_id) DO UPDATE SET {assignments}',
+        (change_id, *cols.values(), *cols.values()),
+    )
+    conn.commit()
+
+
+def set_branch_sha(conn: sqlite3.Connection, change_id: str, branch_sha: str) -> None:
+    """Record the review branch's HEAD, so a rescan can skip an unmoved branch."""
+    _set_change_state(conn, change_id, branch_sha=branch_sha)
+
+
+def get_branch_sha(conn: sqlite3.Connection, change_id: str) -> Optional[str]:
+    """The review branch HEAD recorded at the last rescan, if any."""
+    row = conn.execute(
+        'SELECT branch_sha FROM changes WHERE change_id = ?', (change_id,)
+    ).fetchone()
+    return str(row[0]) if row is not None and row[0] else None
+
+
+def forget_change_state(conn: sqlite3.Connection, change_id: str) -> None:
+    """Drop the per-change_id row once nothing tracks that change_id.
+
+    `changes` outlives `series` on its own -- nothing joins them -- so a
+    change_id that stops existing leaves a row behind carrying
+    back_searched=1 and a branch sha.  Re-tracking it then skips the
+    one-shot backward search for ever, which is the exact opposite of what
+    the latch is for, and a resurrected branch landing on the recorded sha
+    is skipped by rescan_branches.
+    """
+    conn.execute('DELETE FROM changes WHERE change_id = ?', (change_id,))
+
+
 def sync_revisions_catalog_to_branch(
     topdir: Optional[str], identifier: str, change_id: str
 ) -> bool:
@@ -1922,6 +2019,7 @@ def absorb_series_as_revision(
         conn.execute(
             'DELETE FROM series_patches WHERE change_id = ?', (stray_change_id,)
         )
+        forget_change_state(conn, stray_change_id)
     conn.commit()
     return True
 
@@ -3923,13 +4021,7 @@ def rescan_branches(
             continue
         current_sha = sha_out.strip()
 
-        # Check the stored SHA for the most recent revision of this change_id.
-        stored = conn.execute(
-            'SELECT branch_sha FROM series WHERE change_id = ?'
-            ' ORDER BY revision DESC LIMIT 1',
-            (change_id_from_branch,),
-        ).fetchone()
-        if stored and stored['branch_sha'] == current_sha:
+        if get_branch_sha(conn, change_id_from_branch) == current_sha:
             # Branch HEAD unchanged — skip the expensive tracking-commit read.
             scanned_change_ids.add(change_id_from_branch)
             continue
@@ -4021,11 +4113,7 @@ def rescan_branches(
         record_known_revisions(conn, change_id, tracking.get('known-revisions'))
 
         # Persist the new HEAD SHA so future rescans can skip this branch.
-        conn.execute(
-            'UPDATE series SET branch_sha = ? WHERE change_id = ? AND revision = ?',
-            (current_sha, change_id, revision),
-        )
-        conn.commit()
+        set_branch_sha(conn, change_id, str(current_sha))
 
         logger.info('Rescanned: %s (status: %s)', change_id, status)
         changed += 1
@@ -4072,8 +4160,15 @@ def delete_series(
             'DELETE FROM series_patches WHERE change_id = ? AND revision = ?',
             (change_id, revision),
         )
+        # Only once nothing is left: the other revisions still share the
+        # one branch, and the search latch still describes them.
+        if not conn.execute(
+            'SELECT COUNT(*) FROM series WHERE change_id = ?', (change_id,)
+        ).fetchone()[0]:
+            forget_change_state(conn, change_id)
     else:
         conn.execute('DELETE FROM revisions WHERE change_id = ?', (change_id,))
         conn.execute('DELETE FROM series WHERE change_id = ?', (change_id,))
         conn.execute('DELETE FROM series_patches WHERE change_id = ?', (change_id,))
+        forget_change_state(conn, change_id)
     conn.commit()
diff --git a/src/tests/test_review_tracking.py b/src/tests/test_review_tracking.py
index f65bbb4d..8611cd3d 100644
--- a/src/tests/test_review_tracking.py
+++ b/src/tests/test_review_tracking.py
@@ -1646,7 +1646,8 @@ class TestFollowupCounts:
         conn = review_tracking.get_db('fc-migration-test')
         cursor = conn.execute('PRAGMA table_info(series)')
         col_names = {row[1] for row in cursor.fetchall()}
-        assert 'branch_sha' in col_names
+        # branch_sha is added by v2 and rehomed to `changes` by v12.
+        assert 'branch_sha' not in col_names
         assert 'message_count' in col_names
         assert 'seen_message_count' in col_names
         assert 'last_update_check' in col_names

-- 
2.53.0


^ permalink raw reply related	[flat|nested] 26+ messages in thread

* [PATCH RFC v2 11/25] review: test per-revision message tracking
  2026-08-12 21:46 [PATCH RFC v2 00/25] review: track and browse every version of a tracked series Christian Brauner
                   ` (9 preceding siblings ...)
  2026-08-12 21:46 ` [PATCH RFC v2 10/25] review: give per-change_id state its own table Christian Brauner
@ 2026-08-12 21:46 ` Christian Brauner
  2026-08-12 21:46 ` [PATCH RFC v2 12/25] review-tui: poll every revision on u/U updates Christian Brauner
                   ` (13 subsequent siblings)
  24 siblings, 0 replies; 26+ messages in thread
From: Christian Brauner @ 2026-08-12 21:46 UTC (permalink / raw)
  To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Christian Brauner (Amutable)

Cover the v11 migration (column adds, catalog backfill from live and
archived series rows, idempotence), the catalog-owned count reads, the
per-revision poller (first fetch, a quiet poll that records the check
without moving the counts, new-mail bump, error paths, skip statuses,
tracked-row guarantee, least-recently-checked rotation and the cap,
rethreaded first fetch and per-patch incremental, thread-blob caching and
the stitched series blob a re-store must not discard), and the
revision-aware writes in refresh_message_count() and
sync_seen_from_unseen_count().

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 src/tests/test_review_tracking.py | 2902 ++++++++++++++++++++++++++++++++++++-
 1 file changed, 2901 insertions(+), 1 deletion(-)

diff --git a/src/tests/test_review_tracking.py b/src/tests/test_review_tracking.py
index 8611cd3d..c1f20649 100644
--- a/src/tests/test_review_tracking.py
+++ b/src/tests/test_review_tracking.py
@@ -7,7 +7,7 @@ import pathlib
 import re
 import sqlite3
 from email.message import EmailMessage
-from typing import Any, Dict
+from typing import Any, Dict, Optional
 from unittest import mock
 
 import pytest
@@ -1918,6 +1918,30 @@ class TestFollowupBlob:
         result = review_tracking.get_thread_mbox(gitdir, 'deadbeef' * 5)
         assert result is None
 
+    def test_store_revision_thread_blob_reports_a_missing_catalog_row(
+        self, gitdir: str, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """A no-op UPDATE must not read as a stored blob.
+
+        The tracked revision is not guaranteed a catalog row, so a caller
+        handed a synthesized entry took the silent no-op for a cache write
+        and refetched from lore on every call.
+        """
+        conn = review_tracking.init_db('blob-no-row')
+        msgs = [_make_test_msg('only@example.com')]
+
+        assert (
+            review_tracking.store_revision_thread_blob(conn, gitdir, 'cid', 1, msgs)
+            is None
+        )
+
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@example.com')
+        sha = review_tracking.store_revision_thread_blob(conn, gitdir, 'cid', 1, msgs)
+        revs = review_tracking.get_revisions(conn, 'cid')
+        conn.close()
+        assert sha
+        assert revs[0]['thread_blob'] == sha
+
 
 class TestPatchState:
     """Tests for _get_patch_state() and _set_patch_state()."""
@@ -3357,6 +3381,66 @@ class TestAbsorbSeriesAsRevision:
         conn.close()
         assert srow[0] == 0
 
+    def test_absorbing_the_last_series_row_rehomes_the_catalog(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """The stray's other catalogued versions move to the target.
+
+        A stray tracked online carries auto-discovered sibling versions,
+        and a rethreaded one's per-patch message-ids exist nowhere else.
+        Deleting them with the stray's last series row made [l] on a
+        multi-version stray a silent data-loss action.
+        """
+        conn = review_tracking.init_db('mrl-absorb-rehome-test')
+        review_tracking.add_revision(conn, 'series-A', 1, 'a-v1@example.com')
+        _seed_stray_series(conn, 'series-B', 2, 'fp-stray-b')
+        # The stray's catalog knows more than its series row: a
+        # rethreaded v1 colliding with the target's, and a plain v3.
+        review_tracking.add_revision(
+            conn,
+            'series-B',
+            1,
+            'b-v1@example.com',
+            source='discovered',
+            is_rethreaded=True,
+        )
+        _insert_patches(
+            conn, 'series-B', 1, ['b-v1-p1@example.com', 'b-v1-p2@example.com']
+        )
+        review_tracking.add_revision(
+            conn, 'series-B', 3, 'b-v3@example.com', source='discovered'
+        )
+
+        absorbed = review_tracking.absorb_series_as_revision(
+            conn, 'series-A', 'series-B', 2
+        )
+        assert absorbed is True
+
+        revs_a = {
+            r['revision']: r for r in review_tracking.get_revisions(conn, 'series-A')
+        }
+        assert sorted(revs_a) == [1, 2, 3]
+        # The colliding v1 keeps the target's own row, but rescues the
+        # stray's patch list the target lacked -- and the rethread flag
+        # behind which that list is read.
+        assert revs_a[1]['message_id'] == 'a-v1@example.com'
+        assert revs_a[1]['is_rethreaded']
+        patches = review_tracking.get_series_patches(conn, 'series-A', 1)
+        assert [p['message_id'] for p in patches] == [
+            'b-v1-p1@example.com',
+            'b-v1-p2@example.com',
+        ]
+        # The non-colliding v3 is re-homed whole, provenance included.
+        assert revs_a[3]['message_id'] == 'b-v3@example.com'
+        assert revs_a[3]['source'] == 'discovered'
+        # Nothing left under the stray.
+        assert review_tracking.get_revisions(conn, 'series-B') == []
+        leftovers = conn.execute(
+            "SELECT COUNT(*) FROM series_patches WHERE change_id = 'series-B'"
+        ).fetchone()
+        conn.close()
+        assert leftovers[0] == 0
+
     def test_absorb_missing_stray_is_noop(
         self, tmp_path: pytest.TempPathFactory
     ) -> None:
@@ -4382,6 +4466,36 @@ class TestKnownRevisionsCatalog:
             'p2@example.com',
         ]
 
+    def test_the_posting_date_survives_the_round_trip(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """add_revision() defaults found_at to now, which is wrong on replay.
+
+        The catalog is rebuilt from the review branch on a second machine,
+        or after the database is deleted.  Dropping found_at on the way
+        through re-dates every version to the moment of the rebuild, so the
+        version rows and 'posted <date>' all read as today and v1 sorts
+        after vN -- the case found_at was added to prevent.
+        """
+        conn = review_tracking.init_db('rt-found-at')
+        review_tracking.add_revision(
+            conn, 'cid', 1, 'v1@example.com', found_at='2025-11-02T09:15:00+00:00'
+        )
+        review_tracking.add_revision(
+            conn, 'cid', 2, 'v2@example.com', found_at='2026-01-20T18:40:00+00:00'
+        )
+        known = review_tracking.build_known_revisions(conn, 'cid')
+        conn.close()
+
+        conn2 = review_tracking.init_db('rt-found-at2')
+        review_tracking.record_known_revisions(conn2, 'cid', known)
+        revs = review_tracking.get_revisions(conn2, 'cid')
+        conn2.close()
+
+        by_rev = {r['revision']: r['found_at'] for r in revs}
+        assert by_rev[1] == '2025-11-02T09:15:00+00:00'
+        assert by_rev[2] == '2026-01-20T18:40:00+00:00'
+
     def test_record_is_sticky_and_no_downgrade(
         self, tmp_path: pytest.TempPathFactory
     ) -> None:
@@ -5163,3 +5277,2789 @@ class TestUpgradeKeepsTheRethreadFlag:
         )
         conn.close()
         assert rows == {2: 1, 3: 0}
+
+
+def _make_legacy_v10_db(identifier: str) -> str:
+    """Create a schema-v10 database (revisions without count columns)."""
+    path = review_tracking.get_db_path(identifier)
+    conn = sqlite3.connect(path)
+    conn.executescript(
+        """
+        CREATE TABLE schema_version (version INTEGER PRIMARY KEY);
+        CREATE TABLE series (
+            track_id INTEGER PRIMARY KEY,
+            change_id TEXT NOT NULL,
+            revision INTEGER NOT NULL,
+            subject TEXT,
+            sender_name TEXT,
+            sender_email TEXT,
+            sent_at TEXT,
+            added_at TEXT,
+            message_id TEXT,
+            num_patches INTEGER,
+            pw_series_id INTEGER,
+            status TEXT DEFAULT 'new',
+            fingerprint TEXT,
+            branch_sha TEXT,
+            message_count INT,
+            seen_message_count INT,
+            last_update_check TEXT,
+            last_activity_at TEXT,
+            snoozed_until TEXT,
+            attestation TEXT DEFAULT 'pending',
+            target_branch TEXT,
+            is_rethreaded INTEGER DEFAULT 0,
+            UNIQUE (change_id, revision)
+        );
+        CREATE TABLE revisions (
+            change_id   TEXT NOT NULL,
+            revision    INTEGER NOT NULL,
+            message_id  TEXT NOT NULL,
+            subject     TEXT,
+            link        TEXT,
+            found_at    TEXT,
+            thread_blob TEXT,
+            fingerprint TEXT,
+            source      TEXT DEFAULT 'heuristic',
+            is_rethreaded INTEGER DEFAULT 0,
+            PRIMARY KEY (change_id, revision)
+        );
+        """
+    )
+    conn.execute('INSERT INTO schema_version (version) VALUES (10)')
+    # (i) live tracked series with counts and a matching catalog row,
+    # plus a catalog-only older revision.
+    conn.execute(
+        'INSERT INTO series (change_id, revision, subject, message_id, status,'
+        ' message_count, seen_message_count, last_update_check,'
+        ' last_activity_at, added_at)'
+        " VALUES ('cid-live', 2, 'live v2', 'live-v2@x', 'reviewing',"
+        " 8, 6, '2026-07-01T00:00:00+00:00', '2026-06-30T00:00:00+00:00',"
+        " '2026-06-01T00:00:00+00:00')"
+    )
+    conn.execute(
+        'INSERT INTO revisions (change_id, revision, message_id)'
+        " VALUES ('cid-live', 2, 'live-v2@x')"
+    )
+    conn.execute(
+        'INSERT INTO revisions (change_id, revision, message_id)'
+        " VALUES ('cid-live', 1, 'live-v1@x')"
+    )
+    # (ii) archived series row (upgrade leftover) with old counts.
+    conn.execute(
+        'INSERT INTO series (change_id, revision, message_id, status,'
+        ' message_count, seen_message_count)'
+        " VALUES ('cid-live', 1, 'live-v1@x', 'archived', 4, 1)"
+    )
+    # (iii) tracked series with no catalog row at all.
+    conn.execute(
+        'INSERT INTO series (change_id, revision, message_id, status,'
+        ' message_count, seen_message_count)'
+        " VALUES ('cid-norow', 3, 'norow-v3@x', 'new', 5, 5)"
+    )
+    # (iv) series row without a message-id.
+    conn.execute(
+        'INSERT INTO series (change_id, revision, message_id, status)'
+        " VALUES ('cid-nomsgid', 1, '', 'new')"
+    )
+    conn.commit()
+    conn.close()
+    return path
+
+
+class TestSchemaV11RevisionCounts:
+    """Schema v11: per-revision unread tracking lands on the catalog."""
+
+    def test_schema_version_at_least_11(self) -> None:
+        assert review_tracking.SCHEMA_VERSION >= 11
+
+    def test_new_db_has_count_columns(self, tmp_path: pytest.TempPathFactory) -> None:
+        conn = review_tracking.init_db('v11-cols')
+        cols = {row[1] for row in conn.execute('PRAGMA table_info(revisions)')}
+        conn.close()
+        assert {
+            'message_count',
+            'seen_message_count',
+            'last_update_check',
+            'last_mail_at',
+        } <= cols
+
+    def test_migration_adds_columns_and_bumps_version(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        _make_legacy_v10_db('v11-migrate')
+        conn = review_tracking.get_db('v11-migrate')  # runs migration on open
+        cols = {row[1] for row in conn.execute('PRAGMA table_info(revisions)')}
+        version = conn.execute('SELECT version FROM schema_version').fetchone()[0]
+        conn.close()
+        assert 'message_count' in cols
+        assert version == review_tracking.SCHEMA_VERSION
+
+    def test_migration_backfills_catalog_rows(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        _make_legacy_v10_db('v11-backfill')
+        conn = review_tracking.get_db('v11-backfill')
+        norow = review_tracking.get_revisions(conn, 'cid-norow')
+        nomsgid = review_tracking.get_revisions(conn, 'cid-nomsgid')
+        conn.close()
+        # The series row without a catalog entry gets one, carrying counts.
+        assert len(norow) == 1
+        assert norow[0]['message_id'] == 'norow-v3@x'
+        assert norow[0]['message_count'] == 5
+        assert norow[0]['seen_message_count'] == 5
+        # No catalog row is invented without a message-id.
+        assert nomsgid == []
+
+    def test_migration_seeds_existing_catalog_rows(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        _make_legacy_v10_db('v11-seed')
+        conn = review_tracking.get_db('v11-seed')
+        revs = {
+            r['revision']: r for r in review_tracking.get_revisions(conn, 'cid-live')
+        }
+        conn.close()
+        # v2 counts come from the live series row via the stitched read.
+        assert revs[2]['message_count'] == 8
+        assert revs[2]['seen_message_count'] == 6
+        # v1 counts were seeded from the archived series row (the only
+        # historical data) into the catalog columns.
+        assert revs[1]['message_count'] == 4
+        assert revs[1]['seen_message_count'] == 1
+
+    def test_migration_leaves_catalog_activity_unseeded(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """series.last_activity_at is not this version's newest mail.
+
+        It also records maintainer actions, so seeding last_mail_at from it
+        would date a version's thread from the last time someone snoozed
+        the series.  Left NULL until a poll reads a real Date: header --
+        and with one owner there is no second column to fall through to.
+        """
+        _make_legacy_v10_db('v11-activity')
+        conn = review_tracking.get_db('v11-activity')
+        raw = conn.execute(
+            'SELECT last_mail_at FROM revisions'
+            " WHERE change_id = 'cid-live' AND revision = 2"
+        ).fetchone()[0]
+        revs = {
+            r['revision']: r for r in review_tracking.get_revisions(conn, 'cid-live')
+        }
+        conn.close()
+        assert raw is None
+        # ...and the read reports exactly that, rather than borrowing the
+        # series' maintainer-action stamp.
+        assert revs[2]['last_mail_at'] is None
+
+    def test_migration_idempotent(self, tmp_path: pytest.TempPathFactory) -> None:
+        _make_legacy_v10_db('v11-idem')
+        review_tracking.get_db('v11-idem').close()
+        conn = review_tracking.get_db('v11-idem')
+        nrevs = conn.execute('SELECT COUNT(*) FROM revisions').fetchone()[0]
+        version = conn.execute('SELECT version FROM schema_version').fetchone()[0]
+        conn.close()
+        assert nrevs == 3
+        assert version == review_tracking.SCHEMA_VERSION
+
+
+class TestStitchedRevisionReads:
+    """Per-revision counts stitch series (live) over catalog columns."""
+
+    def test_counts_come_from_the_catalog_row(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """One owner, so there is no second copy for a reader to prefer."""
+        conn = review_tracking.init_db('stitch-live')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=1,
+        )
+        conn.execute(
+            'UPDATE revisions SET message_count = 10, seen_message_count = 7'
+            " WHERE change_id = 'cid' AND revision = 2"
+        )
+        conn.commit()
+        revs = review_tracking.get_revisions(conn, 'cid')
+        conn.close()
+        assert revs[0]['message_count'] == 10
+        assert revs[0]['seen_message_count'] == 7
+
+    def test_rethread_flag_is_stitched_too(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """It picks the retrieval path, so both rows must agree on it.
+
+        'e' on the tracked revision's own version row would otherwise
+        reassemble a different thread than 'e' on the series row directly
+        above it.
+        """
+        conn = review_tracking.init_db('stitch-rethread')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=1,
+            is_rethreaded=True,
+        )
+        # The catalog row predates the rethread being recognised.
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+        conn.commit()
+        revs = review_tracking.get_revisions(conn, 'cid')
+        conn.close()
+        assert bool(revs[0]['is_rethreaded'])
+
+    def test_archived_series_does_not_shadow(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        conn = review_tracking.init_db('stitch-arch')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=1,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='v1@x',
+            num_patches=1,
+        )
+        conn.execute(
+            'UPDATE revisions SET message_count = 4, seen_message_count = 0'
+            " WHERE change_id = 'cid'"
+        )
+        conn.execute("UPDATE series SET status = 'archived' WHERE change_id = 'cid'")
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 6, seen_message_count = 6'
+            " WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        revs = review_tracking.get_revisions(conn, 'cid')
+        conn.close()
+        assert revs[0]['message_count'] == 6
+        assert revs[0]['seen_message_count'] == 6
+
+    def test_null_series_counts_fall_back(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        conn = review_tracking.init_db('stitch-null')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=1,
+        )
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 9, seen_message_count = 9'
+            " WHERE change_id = 'cid' AND revision = 2"
+        )
+        conn.commit()
+        revs = review_tracking.get_revisions(conn, 'cid')
+        conn.close()
+        assert revs[0]['message_count'] == 9
+        assert revs[0]['seen_message_count'] == 9
+
+    def test_grouped_returns_full_columns(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        conn = review_tracking.init_db('stitch-grouped')
+        review_tracking.add_revision(
+            conn, 'cid', 1, 'v1@x', fingerprint='fp1', is_rethreaded=True
+        )
+        grouped = review_tracking.get_all_revisions_grouped(conn)
+        conn.close()
+        entry = grouped['cid'][0]
+        assert entry['is_rethreaded']
+        assert entry['fingerprint'] == 'fp1'
+        assert entry['source'] == 'heuristic'
+        assert entry['message_count'] is None
+
+    def test_series_list_agrees_with_its_own_version_row(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """The tracker list and the version rows read the same numbers.
+
+        An upgrade clears the series row's counts, so a raw read renders
+        the parent as '-' while the child row for that very revision,
+        sourced from the catalog, shows a count.
+        """
+        conn = review_tracking.init_db('stitch-list')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='[PATCH v2] thing',
+            sender_name='S',
+            sender_email='s@e.com',
+            sent_at='2026-01-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=1,
+        )
+        review_tracking.add_revision(conn, 'cid', 3, 'v3@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 20, seen_message_count = 12'
+            " WHERE change_id = 'cid' AND revision = 3"
+        )
+        conn.commit()
+        review_tracking.update_series_revision(conn, 'cid', 2, 3, 'v3@x')
+        grouped = review_tracking.get_all_revisions_grouped(conn)
+        conn.close()
+
+        series = {
+            s['change_id']: s
+            for s in review_tracking.get_all_tracked_series('stitch-list')
+        }['cid']
+        child = {r['revision']: r for r in grouped['cid']}[3]
+        assert series['revision'] == 3
+        assert series['message_count'] == child['message_count'] == 20
+        assert series['seen_message_count'] == child['seen_message_count'] == 12
+
+
+def _thread_msgs(count: int, base: str = 'm') -> list[EmailMessage]:
+    """Build a minimal thread of EmailMessage objects with Date headers."""
+    msgs = []
+    for i in range(count):
+        msg = EmailMessage()
+        msg['Subject'] = f'Re: thread {i}'
+        msg['From'] = 'Dev <dev@example.com>'
+        msg['Message-Id'] = f'<{base}-{i}@example.com>'
+        msg['Date'] = f'Thu, {i + 1:02d} Jul 2026 08:00:00 +0000'
+        msg.set_payload('body\n')
+        msgs.append(msg)
+    return msgs
+
+
+def _poller_series(
+    change_id: str, revision: int, message_id: str, status: str = 'new'
+) -> Dict[str, Any]:
+    """Series dict shaped like the TUI's loaded rows, for the poller."""
+    return {
+        'change_id': change_id,
+        'revision': revision,
+        'message_id': message_id,
+        'subject': 'test subject',
+        'status': status,
+    }
+
+
+class TestFailedPollsSpendTheBudget:
+    """The cap counts lore round-trips, and a fetch that fails made one.
+
+    Two deliberate rules combine badly otherwise: naming the revisions
+    lifts the two-in-a-row break (dead message-ids are exactly what a
+    backward search turns up), so a version whose ids all 404 walked every
+    one of them however small the cap.
+    """
+
+    @staticmethod
+    def _seed(identifier: str) -> None:
+        conn = review_tracking.init_db(identifier)
+        for rev in (1, 2, 3, 4, 5):
+            review_tracking.add_revision(conn, 'cid', rev, f'v{rev}@x')
+        conn.close()
+
+    def _run(
+        self, identifier: str, monkeypatch: pytest.MonkeyPatch, online: bool
+    ) -> list[int]:
+        self._seed(identifier)
+        tried: list[int] = []
+
+        def _fail(ident: str, conn: Any, change_id: str, rev: Dict[str, Any]) -> None:
+            tried.append(int(rev['revision']))
+            return None
+
+        monkeypatch.setattr(b4, 'can_network', online)
+        monkeypatch.setattr(review_tracking, '_fetch_revision_thread_msgs', _fail)
+        review_tracking.update_revision_message_counts(
+            identifier,
+            [_poller_series('cid', 6, 'v6@x')],
+            only_revisions={1, 2, 3, 4, 5},
+            max_revisions_per_series=2,
+        )
+        return tried
+
+    def test_the_cap_bounds_a_run_of_failures(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        assert len(self._run('poll-budget', monkeypatch, online=True)) == 2
+
+    def test_offline_spends_nothing(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """No request was made, so there is no round-trip to charge for.
+
+        Charging them would make an offline sweep look like a series that
+        had used up its budget, and the revisions behind the cap would wait
+        a sweep for nothing.
+        """
+        assert len(self._run('poll-budget-off', monkeypatch, online=False)) == 5
+
+
+class TestUpdateRevisionMessageCounts:
+    """The per-revision poller for non-tracked versions."""
+
+    def test_first_fetch_initializes_counts(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        conn = review_tracking.init_db('poll-first')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='test subject',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=1,
+        )
+        # Leave only the manual-link gap the backfill exists to close.
+        conn.execute('DELETE FROM revisions WHERE revision = 2')
+        conn.commit()
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.close()
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: _thread_msgs(3),
+        )
+        result = review_tracking.update_revision_message_counts(
+            'poll-first', [_poller_series('cid', 2, 'v2@x')]
+        )
+        assert result == {
+            'updated': 1,
+            'new_mail': 0,
+            'errors': 0,
+            'fresh_errors': 0,
+            'polled': 1,
+            'cancelled': 0,
+        }
+        conn = review_tracking.get_db('poll-first')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[1]['message_count'] == 3
+        assert revs[1]['seen_message_count'] == 3
+        assert revs[1]['last_update_check'] is not None
+        assert revs[1]['last_mail_at'] is not None
+        # The tracked revision's row was backfilled but not polled.
+        assert revs[2]['message_count'] is None
+
+    def test_quiet_poll_leaves_the_counts_alone(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """A quiet revision records the check but not a new count.
+
+        The check has to be recorded or the poll rotation never advances
+        past it (see TestPollCapFairness); what must not move is the
+        count/seen pair the unread badge is derived from.
+        """
+        conn = review_tracking.init_db('poll-quiet')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 5, seen_message_count = 3,'
+            " last_update_check = '2026-07-01T00:00:00+00:00',"
+            " last_mail_at = '2026-06-30T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+        # The refetch finds the same 5 messages.
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: _thread_msgs(5),
+        )
+        result = review_tracking.update_revision_message_counts(
+            'poll-quiet', [_poller_series('cid', 2, 'v2@x')]
+        )
+        assert result == {
+            'updated': 0,
+            'new_mail': 0,
+            'errors': 0,
+            'fresh_errors': 0,
+            'polled': 1,
+            'cancelled': 0,
+        }
+        conn = review_tracking.get_db('poll-quiet')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        # The badge is untouched: still 2 unseen, and no fresh activity.
+        assert revs[1]['message_count'] == 5
+        assert revs[1]['seen_message_count'] == 3
+        assert revs[1]['last_mail_at'] == '2026-06-30T00:00:00+00:00'
+        # But the rotation moved on.
+        assert revs[1]['last_update_check'] > '2026-07-01T00:00:00+00:00'
+
+    def test_a_shorter_thread_still_becomes_the_cached_thread(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """thread_blob is "the thread as it last looked", so the fetch wins.
+
+        Threads do shrink, and the blob is what the next sweep diffs
+        against to decide which messages are new -- keeping a fuller older
+        snapshot would re-count the difference as fresh mail for ever.  A
+        range-diff is not what this column answers: that reads
+        ``series_blob``, which :func:`set_revision_thread_blob` leaves
+        alone unless the thread itself changed.
+        """
+        conn = review_tracking.init_db('poll-blobshrink')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 4, seen_message_count = 4,'
+            " thread_blob = 'cafebabe'"
+            " WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: _thread_msgs(1),
+        )
+        stored: list[int] = []
+        monkeypatch.setattr(
+            review_tracking,
+            'store_revision_thread_blob',
+            lambda conn, topdir, change_id, revision, msgs: stored.append(len(msgs)),
+        )
+        review_tracking.update_revision_message_counts(
+            'poll-blobshrink', [_poller_series('cid', 2, 'v2@x')], topdir='/nonexistent'
+        )
+        conn = review_tracking.get_db('poll-blobshrink')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert (revs[1]['message_count'], revs[1]['seen_message_count']) == (1, 1)
+        assert stored == [1]
+
+    def test_a_changed_thread_drops_the_stitched_series_blob(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """A stitch is only as good as the thread it was built from.
+
+        The patch that made a version unstitchable may be exactly what
+        just landed, so a new thread retires the series blob built from
+        the old one and the next range-diff stitches again.
+        """
+        conn = review_tracking.init_db('poll-blobstitch')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 4, seen_message_count = 4,'
+            " thread_blob = 'cafebabe', series_blob = 'deadbeef'"
+            " WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: _thread_msgs(6),
+        )
+        monkeypatch.setattr(
+            review_tracking, '_write_mbox_blob', lambda topdir, msgs: 'feedface'
+        )
+        review_tracking.update_revision_message_counts(
+            'poll-blobstitch', [_poller_series('cid', 2, 'v2@x')], topdir='/nonexistent'
+        )
+        conn = review_tracking.get_db('poll-blobstitch')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[1]['thread_blob'] == 'feedface'
+        assert revs[1]['series_blob'] is None
+
+    def test_an_unchanged_thread_keeps_the_stitched_series_blob(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Re-storing the same thread must not throw the stitch away.
+
+        The blob is content-addressed, so a thread that has not moved
+        hashes to the SHA already on the row -- and a sweep that retired
+        the series blob on every such write would undo the caching it
+        exists to provide.
+        """
+        conn = review_tracking.init_db('poll-blobkeep')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 4, seen_message_count = 4,'
+            " thread_blob = 'cafebabe', series_blob = 'deadbeef'"
+            " WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+        assert (
+            review_tracking.set_revision_thread_blob(
+                review_tracking.get_db('poll-blobkeep'), 'cid', 1, 'cafebabe'
+            )
+            is True
+        )
+        conn = review_tracking.get_db('poll-blobkeep')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[1]['series_blob'] == 'deadbeef'
+
+    def test_a_cancel_between_series_is_reported(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """The outer cancel check must not present a clean, complete sweep."""
+        review_tracking.init_db('poll-cancel-outer').close()
+        result = review_tracking.update_revision_message_counts(
+            'poll-cancel-outer',
+            [_poller_series('cid', 2, 'v2@x')],
+            cancel_cb=lambda: True,
+        )
+        assert result['cancelled'] == 1
+        assert result['polled'] == 0
+
+    def test_a_missing_db_returns_the_documented_contract(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """The early return carries every key the docstring promises."""
+        result = review_tracking.update_revision_message_counts(
+            'poll-no-such-db', [_poller_series('cid', 2, 'v2@x')]
+        )
+        assert result == {
+            'updated': 0,
+            'new_mail': 0,
+            'errors': 0,
+            'fresh_errors': 0,
+            'polled': 0,
+            'cancelled': 0,
+        }
+
+    def test_first_fetch_is_not_reported_as_new_mail(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Two revisions never counted before, plus one that got mail.
+
+        The sweep summary quotes 'new_mail', so a catalog that has just
+        grown per-revision columns must not read as activity everywhere.
+        """
+        conn = review_tracking.init_db('poll-firstmail')
+        for rev, msgid in ((1, 'v1@x'), (2, 'v2@x'), (3, 'v3@x')):
+            review_tracking.add_revision(conn, 'cid', rev, msgid)
+        conn.execute(
+            'UPDATE revisions SET message_count = 5, seen_message_count = 5,'
+            " last_update_check = '2026-07-01T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 2"
+        )
+        conn.commit()
+        conn.close()
+        # v1 has never been counted; v2 was at 5 and has grown to 7.
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: _thread_msgs(
+                7 if int(rev['revision']) == 2 else 2
+            ),
+        )
+        result = review_tracking.update_revision_message_counts(
+            'poll-firstmail', [_poller_series('cid', 3, 'v3@x')]
+        )
+        assert result == {
+            'updated': 2,
+            'new_mail': 1,
+            'errors': 0,
+            'fresh_errors': 0,
+            'polled': 2,
+            'cancelled': 0,
+        }
+
+    def test_new_mail_bumps_count(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        conn = review_tracking.init_db('poll-new')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 5, seen_message_count = 5,'
+            " last_update_check = '2026-07-01T00:00:00+00:00',"
+            " last_mail_at = '2026-06-30T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: _thread_msgs(7),
+        )
+        result = review_tracking.update_revision_message_counts(
+            'poll-new', [_poller_series('cid', 2, 'v2@x')]
+        )
+        assert result == {
+            'updated': 1,
+            'new_mail': 1,
+            'errors': 0,
+            'fresh_errors': 0,
+            'polled': 1,
+            'cancelled': 0,
+        }
+        conn = review_tracking.get_db('poll-new')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[1]['message_count'] == 7
+        assert revs[1]['seen_message_count'] == 5
+        assert revs[1]['last_mail_at'] > '2026-06-30T00:00:00+00:00'
+        assert revs[1]['last_update_check'] > '2026-07-01T00:00:00+00:00'
+
+    def test_fetch_error_counts_error(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        conn = review_tracking.init_db('poll-err')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 5, seen_message_count = 5,'
+            " last_update_check = '2026-07-01T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: None,
+        )
+        result = review_tracking.update_revision_message_counts(
+            'poll-err', [_poller_series('cid', 2, 'v2@x')]
+        )
+        assert result == {
+            'updated': 0,
+            'new_mail': 0,
+            'errors': 1,
+            'fresh_errors': 1,
+            'polled': 0,
+            'cancelled': 0,
+        }
+        conn = review_tracking.get_db('poll-err')
+        revs = review_tracking.get_revisions(conn, 'cid')
+        conn.close()
+        assert revs[0]['message_count'] == 5
+
+    def test_skip_statuses_not_polled(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        conn = review_tracking.init_db('poll-skip')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.close()
+        calls: list[int] = []
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: calls.append(rev['revision']),
+        )
+        for status in ('archived', 'snoozed'):
+            result = review_tracking.update_revision_message_counts(
+                'poll-skip', [_poller_series('cid', 2, 'v2@x', status=status)]
+            )
+            assert result == {
+                'updated': 0,
+                'new_mail': 0,
+                'errors': 0,
+                'fresh_errors': 0,
+                'polled': 0,
+                'cancelled': 0,
+            }
+        assert calls == []
+
+    def test_applied_series_is_still_polled(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """A late reply to an old version still counts once a series lands.
+
+        The tracked revision is deliberately polled past 'accepted' so
+        follow-up discussion on an applied series keeps raising a badge;
+        its older versions must not be dropped at the same moment.
+        """
+        conn = review_tracking.init_db('poll-applied')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.close()
+        calls: list[int] = []
+
+        def _fetch(identifier: str, conn: Any, change_id: str, rev: Any) -> Any:
+            calls.append(int(rev['revision']))
+            return _thread_msgs(3)
+
+        monkeypatch.setattr(review_tracking, '_fetch_revision_thread_msgs', _fetch)
+        for status in ('accepted', 'thanked'):
+            calls.clear()
+            # The first pass stamps the check; age it back out so the
+            # second status is not skipped by the minimum-interval gate.
+            conn = review_tracking.get_db('poll-applied')
+            conn.execute('UPDATE revisions SET last_update_check = NULL')
+            conn.commit()
+            conn.close()
+            review_tracking.update_revision_message_counts(
+                'poll-applied', [_poller_series('cid', 2, 'v2@x', status=status)]
+            )
+            assert calls == [1]
+
+    def test_tracked_revision_not_polled(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        conn = review_tracking.init_db('poll-tracked')
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+        conn.close()
+        calls: list[int] = []
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: calls.append(rev['revision']),
+        )
+        result = review_tracking.update_revision_message_counts(
+            'poll-tracked', [_poller_series('cid', 2, 'v2@x')]
+        )
+        assert result == {
+            'updated': 0,
+            'new_mail': 0,
+            'errors': 0,
+            'fresh_errors': 0,
+            'polled': 0,
+            'cancelled': 0,
+        }
+        assert calls == []
+
+    def test_tracked_row_backfilled_when_missing(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """Seeded from the series table, the authority the dict mirrors."""
+        conn = review_tracking.init_db('poll-backfill')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='test subject',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=1,
+        )
+        # Simulate the manual-link gap: the series row exists, its
+        # revision's catalog row does not.
+        conn.execute('DELETE FROM revisions')
+        conn.commit()
+        conn.close()
+        review_tracking.update_revision_message_counts(
+            'poll-backfill', [_poller_series('cid', 2, 'v2@x')]
+        )
+        conn = review_tracking.get_db('poll-backfill')
+        revs = review_tracking.get_revisions(conn, 'cid')
+        conn.close()
+        assert len(revs) == 1
+        assert revs[0]['revision'] == 2
+        assert revs[0]['message_id'] == 'v2@x'
+        assert revs[0]['message_count'] is None
+
+    def test_a_recent_check_is_skipped(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Below the cap the rotation never engages; the age floor must.
+
+        With every candidate under the cap, an uncapped rotation re-fetches
+        each quiet old version's full thread on every sweep just to learn
+        it is still quiet -- thousands of lore round-trips a day on a
+        30-minute cron for a list of any size.
+        """
+        recent = datetime.datetime.now(datetime.timezone.utc).isoformat()
+        conn = review_tracking.init_db('poll-fresh')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 5, seen_message_count = 5,'
+            ' last_update_check = ?',
+            (recent,),
+        )
+        conn.commit()
+        conn.close()
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: pytest.fail(
+                'a freshly checked revision must not be fetched'
+            ),
+        )
+        result = review_tracking.update_revision_message_counts(
+            'poll-fresh', [_poller_series('cid', 2, 'v2@x')]
+        )
+        assert result['polled'] == 0
+
+    def test_named_revisions_bypass_the_age_floor(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """only_revisions means the caller is asking right now."""
+        recent = datetime.datetime.now(datetime.timezone.utc).isoformat()
+        conn = review_tracking.init_db('poll-named')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 5, seen_message_count = 5,'
+            ' last_update_check = ?',
+            (recent,),
+        )
+        conn.commit()
+        conn.close()
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: _thread_msgs(5),
+        )
+        result = review_tracking.update_revision_message_counts(
+            'poll-named', [_poller_series('cid', 2, 'v2@x')], only_revisions={1}
+        )
+        assert result['polled'] == 1
+
+    def test_newest_first_with_cap(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        conn = review_tracking.init_db('poll-cap')
+        for rev in (1, 2, 3, 4):
+            review_tracking.add_revision(conn, 'cid', rev, f'v{rev}@x')
+        conn.close()
+        polled: list[int] = []
+
+        def _fake_fetch(
+            identifier: str, conn: Any, change_id: str, rev: Dict[str, Any]
+        ) -> list[EmailMessage]:
+            polled.append(int(rev['revision']))
+            return _thread_msgs(2)
+
+        monkeypatch.setattr(review_tracking, '_fetch_revision_thread_msgs', _fake_fetch)
+        result = review_tracking.update_revision_message_counts(
+            'poll-cap',
+            [_poller_series('cid', 4, 'v4@x')],
+            max_revisions_per_series=1,
+        )
+        assert result == {
+            'updated': 1,
+            'new_mail': 0,
+            'errors': 0,
+            'fresh_errors': 0,
+            'polled': 1,
+            'cancelled': 0,
+        }
+        assert polled == [3]
+
+    def test_rethreaded_first_fetch_reassembles(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        conn = review_tracking.init_db('poll-rt-first')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x', is_rethreaded=True)
+        _insert_patches(conn, 'cid', 1, ['p1@x', 'p2@x'])
+        conn.close()
+        seen_dicts: list[Dict[str, Any]] = []
+
+        def _fake_retrieve(
+            series: Dict[str, Any], identifier: str
+        ) -> list[EmailMessage]:
+            seen_dicts.append(series)
+            return _thread_msgs(4)
+
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(b4.review, 'retrieve_series_messages', _fake_retrieve)
+        result = review_tracking.update_revision_message_counts(
+            'poll-rt-first', [_poller_series('cid', 2, 'v2@x')]
+        )
+        assert result == {
+            'updated': 1,
+            'new_mail': 0,
+            'errors': 0,
+            'fresh_errors': 0,
+            'polled': 1,
+            'cancelled': 0,
+        }
+        assert seen_dicts and seen_dicts[0]['is_rethreaded'] is True
+        assert seen_dicts[0]['revision'] == 1
+        conn = review_tracking.get_db('poll-rt-first')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[1]['message_count'] == 4
+
+    def test_rethreaded_recount_reassembles_from_patches(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """An already-counted rethreaded revision recounts the same way.
+
+        Summing per-patch queries counted a reply CC'd into several patch
+        threads once per thread; reassembly dedupes it.
+        """
+        conn = review_tracking.init_db('poll-rt-incr')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x', is_rethreaded=True)
+        _insert_patches(conn, 'cid', 1, ['p1@x', 'p2@x'])
+        conn.execute(
+            'UPDATE revisions SET message_count = 6, seen_message_count = 6,'
+            " last_update_check = '2026-07-01T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+        seen_series: list[Dict[str, Any]] = []
+
+        def _reassemble(series: Dict[str, Any], identifier: str) -> list[EmailMessage]:
+            seen_series.append(series)
+            return _thread_msgs(8)
+
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(b4.review, 'retrieve_series_messages', _reassemble)
+        result = review_tracking.update_revision_message_counts(
+            'poll-rt-incr', [_poller_series('cid', 2, 'v2@x')]
+        )
+        assert result == {
+            'updated': 1,
+            'new_mail': 1,
+            'errors': 0,
+            'fresh_errors': 0,
+            'polled': 1,
+            'cancelled': 0,
+        }
+        assert [s['revision'] for s in seen_series] == [1]
+        assert seen_series[0]['is_rethreaded'] is True
+        conn = review_tracking.get_db('poll-rt-incr')
+        revs = review_tracking.get_revisions(conn, 'cid')
+        conn.close()
+        assert revs[0]['message_count'] == 8
+        # Seen is untouched, so the two new messages raise a badge.
+        assert revs[0]['seen_message_count'] == 6
+
+    def test_first_fetch_stores_blob_with_topdir(
+        self, gitdir: str, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        conn = review_tracking.init_db('poll-blob')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.close()
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: _thread_msgs(2),
+        )
+        review_tracking.update_revision_message_counts(
+            'poll-blob', [_poller_series('cid', 2, 'v2@x')], topdir=gitdir
+        )
+        conn = review_tracking.get_db('poll-blob')
+        revs = review_tracking.get_revisions(conn, 'cid')
+        conn.close()
+        blob_sha = revs[0]['thread_blob']
+        assert blob_sha
+        mbox = review_tracking.get_thread_mbox(gitdir, blob_sha)
+        assert mbox is not None
+        assert b'm-0@example.com' in mbox
+
+
+class TestRevisionAwareSyncHelpers:
+    """refresh_message_count / sync_seen fall back to the catalog."""
+
+    def _seed(self, identifier: str) -> None:
+        conn = review_tracking.init_db(identifier)
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=1,
+        )
+        conn.execute(
+            'UPDATE revisions SET message_count = 8, seen_message_count = 8'
+            " WHERE change_id = 'cid'"
+        )
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 6, seen_message_count = 6'
+            " WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.execute(
+            'UPDATE revisions SET message_count = 3, seen_message_count = 3'
+            " WHERE change_id = 'cid' AND revision = 2"
+        )
+        conn.commit()
+        conn.close()
+
+    def test_sync_seen_writes_the_named_revisions_row(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """No routing decision left: the revision named is the row written."""
+        self._seed('sync-live')
+        assert review_tracking.sync_seen_from_unseen_count('sync-live', 'cid', 2, 2)
+        conn = review_tracking.get_db('sync-live')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        # v2 holds 3 messages, 2 of them unseen.
+        assert revs[2]['seen_message_count'] == 1
+        # ...and the version beside it is untouched.
+        assert revs[1]['seen_message_count'] == 6
+
+    def test_sync_seen_falls_back_to_catalog(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        self._seed('sync-fall')
+        assert review_tracking.sync_seen_from_unseen_count('sync-fall', 'cid', 1, 4)
+        conn = review_tracking.get_db('sync-fall')
+        rev_seen = conn.execute(
+            'SELECT seen_message_count FROM revisions'
+            " WHERE change_id = 'cid' AND revision = 1"
+        ).fetchone()[0]
+        conn.close()
+        assert rev_seen == 2
+
+    def test_sync_seen_ignores_the_series_status(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """An archived series row used to shadow, then have to be excluded.
+
+        Read state never sat on it, so its status cannot affect a badge and
+        the write needs no guard against it.
+        """
+        self._seed('sync-arch')
+        conn = review_tracking.get_db('sync-arch')
+        review_tracking.update_series_status(conn, 'cid', 'archived')
+        conn.close()
+        assert review_tracking.sync_seen_from_unseen_count('sync-arch', 'cid', 2, 1)
+        conn = review_tracking.get_db('sync-arch')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[2]['seen_message_count'] == 2
+
+    def test_sync_seen_no_rows_returns_false(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        review_tracking.init_db('sync-none').close()
+        assert not review_tracking.sync_seen_from_unseen_count('sync-none', 'cid', 9, 1)
+
+    def test_refresh_count_falls_back_to_catalog(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        conn = review_tracking.init_db('refresh-fall')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.close()
+        assert review_tracking.refresh_message_count('refresh-fall', 'cid', 1, 7)
+        conn = review_tracking.get_db('refresh-fall')
+        revs = review_tracking.get_revisions(conn, 'cid')
+        conn.close()
+        # First fetch initialises both counts equally (no badge).
+        assert revs[0]['message_count'] == 7
+        assert revs[0]['seen_message_count'] == 7
+
+    def test_refresh_count_unchanged_writes_nothing(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """A count that has not moved leaves the DB mtime alone."""
+        self._seed('refresh-skip')
+        assert not review_tracking.refresh_message_count('refresh-skip', 'cid', 2, 3)
+
+    def test_mark_all_messages_seen_clears_the_badge(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """One row to clear, and its own total is what the badge showed."""
+        self._seed('mark-rev')
+        conn = review_tracking.get_db('mark-rev')
+        conn.execute(
+            'UPDATE revisions SET seen_message_count = 1'
+            " WHERE change_id = 'cid' AND revision = 2"
+        )
+        conn.commit()
+        review_tracking.mark_all_messages_seen(conn, 'cid', 2)
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[2]['seen_message_count'] == revs[2]['message_count'] == 3
+        # The other version keeps whatever it had.
+        assert revs[1]['seen_message_count'] == 6
+
+    def test_marking_seen_uses_the_rows_own_total(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """The count on the row is the count that was displayed.
+
+        This used to need a clamp: the badge came from a stitched read, so
+        a catalog row that ran ahead of the series row held messages the
+        list never showed.  With one copy the two cannot diverge.
+        """
+        self._seed('mark-ahead')
+        conn = review_tracking.get_db('mark-ahead')
+        conn.execute(
+            'UPDATE revisions SET message_count = 14, seen_message_count = 10'
+            " WHERE change_id = 'cid' AND revision = 2"
+        )
+        conn.commit()
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        assert revs[2]['message_count'] == 14
+        review_tracking.mark_all_messages_seen(conn, 'cid', 2)
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[2]['seen_message_count'] == 14
+
+
+class TestRevisionCountsSurviveSeriesMoves:
+    """A series row only holds the tracked revision's counts.
+
+    Re-pointing or retiring one must hand that read state to the catalog,
+    which is where every non-tracked revision keeps it.
+    """
+
+    def _seed(self, identifier: str, revision: int = 2) -> sqlite3.Connection:
+        conn = review_tracking.init_db(identifier)
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=revision,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id=f'v{revision}@x',
+            num_patches=3,
+        )
+        conn.execute(
+            'UPDATE revisions SET message_count = 12, seen_message_count = 9,'
+            " last_update_check = '2026-06-05T00:00:00+00:00',"
+            " last_mail_at = '2026-06-04T00:00:00+00:00'"
+            ' WHERE change_id = ? AND revision = ?',
+            ('cid', revision),
+        )
+        conn.commit()
+        return conn
+
+    def test_upgrade_parks_counts_in_catalog(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        conn = self._seed('carry-upgrade')
+        review_tracking.update_series_revision(conn, 'cid', 2, 3, 'v3@x')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[2]['message_count'] == 12
+        assert revs[2]['seen_message_count'] == 9
+        assert revs[2]['message_id'] == 'v2@x'
+        assert revs[2]['last_mail_at'] == '2026-06-04T00:00:00+00:00'
+
+    def test_upgrade_defers_to_a_newer_catalog_row(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        conn = self._seed('carry-nooverwrite')
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 20, seen_message_count = 20,'
+            " last_update_check = '2026-06-09T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 2"
+        )
+        conn.commit()
+        review_tracking.update_series_revision(conn, 'cid', 2, 3, 'v3@x')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[2]['message_count'] == 20
+
+    def test_an_upgrade_leaves_the_old_revision_alone(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """Nothing is copied on an upgrade, so nothing can overwrite.
+
+        The outgoing revision's counts were never on the series row: they
+        are its own, and moving off it does not touch them.
+        """
+        conn = self._seed('carry-overwrite')
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 8, seen_message_count = 8,'
+            " last_update_check = '2026-06-01T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 2"
+        )
+        conn.commit()
+        review_tracking.update_series_revision(conn, 'cid', 2, 3, 'v3@x')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[2]['message_count'] == 8
+        assert revs[2]['seen_message_count'] == 8
+        assert revs[2]['last_update_check'] == '2026-06-01T00:00:00+00:00'
+
+    def test_an_upgrade_needs_no_staleness_check(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """Which copy is newer was only ever a question with two copies."""
+        conn = self._seed('carry-untimed')
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 20, seen_message_count = 20'
+            " WHERE change_id = 'cid' AND revision = 2"
+        )
+        conn.commit()
+        review_tracking.update_series_revision(conn, 'cid', 2, 3, 'v3@x')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        # No timestamp anywhere, and still unambiguous.
+        assert revs[2]['message_count'] == 20
+
+    def test_absorb_carries_stray_counts(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        conn = review_tracking.init_db('carry-absorb')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='target',
+            revision=3,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='v3@x',
+            num_patches=3,
+        )
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='stray',
+            revision=2,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-05-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=3,
+        )
+        conn.execute(
+            'UPDATE revisions SET message_count = 15, seen_message_count = 11'
+            " WHERE change_id = 'stray'"
+        )
+        conn.commit()
+        assert review_tracking.absorb_series_as_revision(conn, 'target', 'stray', 2)
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'target')}
+        conn.close()
+        assert revs[2]['message_count'] == 15
+        assert revs[2]['seen_message_count'] == 11
+
+    def test_absorb_carries_counts_held_only_by_the_catalog(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """A stray's counts commonly live in its own catalog row.
+
+        The per-revision read COALESCEs over both tables, so a stray whose
+        series row never carried counts still displays them -- and absorb
+        deletes that catalog row, so reading only the series row drops the
+        state the user was looking at.
+        """
+        conn = review_tracking.init_db('carry-absorb-catalog')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='target',
+            revision=3,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='v3@x',
+            num_patches=3,
+        )
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='stray',
+            revision=2,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-05-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=3,
+        )
+        review_tracking.add_revision(conn, 'stray', 2, 'v2@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 12, seen_message_count = 5'
+            " WHERE change_id = 'stray' AND revision = 2"
+        )
+        conn.commit()
+        assert review_tracking.absorb_series_as_revision(conn, 'target', 'stray', 2)
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'target')}
+        conn.close()
+        assert revs[2]['message_count'] == 12
+        assert revs[2]['seen_message_count'] == 5
+
+    def test_absorb_uses_the_revision_the_caller_matched(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """A stray tracked across versions must not contribute the wrong one."""
+        conn = review_tracking.init_db('carry-absorb-multi')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='target',
+            revision=5,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='t5@x',
+            num_patches=3,
+        )
+        for rev, count in ((2, 4), (3, 30)):
+            review_tracking.add_series_to_db(
+                conn,
+                change_id='stray',
+                revision=rev,
+                subject='s',
+                sender_name='n',
+                sender_email='e@x',
+                sent_at='2026-06-01T00:00:00+00:00',
+                message_id=f'stray-v{rev}@x',
+                num_patches=1,
+            )
+            conn.execute(
+                'UPDATE revisions SET message_count = ?, seen_message_count = 0'
+                " WHERE change_id = 'stray' AND revision = ?",
+                (count, rev),
+            )
+        conn.commit()
+        assert review_tracking.absorb_series_as_revision(
+            conn, 'target', 'stray', 2, stray_revision=2
+        )
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'target')}
+        conn.close()
+        # v3's message-id and counts must not arrive labelled as v2.
+        assert revs[2]['message_id'] == 'stray-v2@x'
+        assert revs[2]['message_count'] == 4
+
+    def test_absorb_refuses_a_revision_the_stray_does_not_track(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """A named revision is the only candidate, never a hint.
+
+        The catalog can hold a revision the stray never had a series row
+        for, and falling back to another of its versions would file that
+        posting under the message-id the caller matched.
+        """
+        conn = review_tracking.init_db('carry-absorb-missing')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='target',
+            revision=5,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='t5@x',
+            num_patches=3,
+        )
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='stray',
+            revision=3,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='stray-v3@x',
+            num_patches=1,
+        )
+        conn.execute(
+            'UPDATE revisions SET message_count = 30, seen_message_count = 0'
+            " WHERE change_id = 'stray' AND revision = 3"
+        )
+        conn.commit()
+
+        assert (
+            review_tracking.absorb_series_as_revision(
+                conn, 'target', 'stray', 2, stray_revision=2
+            )
+            is False
+        )
+        revs = review_tracking.get_revisions(conn, 'target')
+        stray_rows = conn.execute(
+            "SELECT COUNT(*) FROM series WHERE change_id = 'stray'"
+        ).fetchone()[0]
+        conn.close()
+        # The refused absorb recorded nothing, and the stray is untouched --
+        # v3's message-id must not turn up labelled v2.  The target's own
+        # v5 row is there because tracking a series catalogues the revision
+        # it tracks, which is where that revision's read state lives.
+        assert [r['revision'] for r in revs] == [5]
+        assert stray_rows == 1
+
+    def test_archiving_parks_counts_in_catalog(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """Upgrading a checked-out series archives the outgoing row.
+
+        Per-revision reads skip archived rows, so the counts have to
+        reach the catalog before the status flips.
+        """
+        conn = self._seed('carry-archive', revision=1)
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        review_tracking.update_series_status(conn, 'cid', 'archived', revision=1)
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-10T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=3,
+        )
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[1]['message_count'] == 12
+        assert revs[1]['seen_message_count'] == 9
+
+    def test_archiving_backfills_a_missing_catalog_row(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """A manually linked series can lack a catalog row entirely."""
+        conn = self._seed('carry-archive-nocatalog', revision=1)
+        review_tracking.update_series_status(conn, 'cid', 'archived')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[1]['message_id'] == 'v1@x'
+        assert revs[1]['message_count'] == 12
+        assert revs[1]['seen_message_count'] == 9
+
+    def test_parking_dates_the_revision_it_retires(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """found_at is when the version was posted, not when it was retired."""
+        conn = self._seed('carry-founddate', revision=2)
+        conn.execute(
+            "UPDATE series SET added_at = '2026-06-02T00:00:00+00:00'"
+            " WHERE change_id = 'cid'"
+        )
+        conn.commit()
+        review_tracking.update_series_revision(conn, 'cid', 2, 3, 'v3@x')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        # Dating it now would sort the older version after the newer one, and
+        # dating it added_at reports when tracking started -- sent_at is the
+        # Date: header the row claims to be showing.
+        assert revs[2]['found_at'] == '2026-06-01T00:00:00+00:00'
+
+    def test_archiving_keeps_the_revisions_watermark(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """A NULL watermark sorts to the head of the poll rotation forever.
+
+        Archiving used to park counts, which could spread the incoming
+        revision's cleared watermark onto the one being retired.  It now
+        writes no read state at all.
+        """
+        conn = self._seed('carry-nullmark', revision=2)
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 4, seen_message_count = 4,'
+            " last_update_check = '2026-06-02T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 2"
+        )
+        conn.commit()
+        review_tracking.update_series_status(conn, 'cid', 'archived', revision=2)
+        row = conn.execute(
+            'SELECT last_update_check FROM revisions'
+            " WHERE change_id = 'cid' AND revision = 2"
+        ).fetchone()
+        conn.close()
+        assert row[0] == '2026-06-02T00:00:00+00:00'
+
+    def test_parking_does_not_overwrite_polled_activity(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """series.last_activity_at also stamps maintainer actions.
+
+        The catalog column only ever holds a real Date: header, so a
+        snooze or a status change must not replace one.
+        """
+        conn = self._seed('carry-activity', revision=2)
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 4, seen_message_count = 4,'
+            " last_mail_at = '2026-03-01T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 2"
+        )
+        # A maintainer action bumps the series stamp to something newer.
+        conn.execute(
+            "UPDATE series SET last_activity_at = '2026-07-01T00:00:00+00:00'"
+            " WHERE change_id = 'cid'"
+        )
+        conn.commit()
+        review_tracking.update_series_status(conn, 'cid', 'archived', revision=2)
+        row = conn.execute(
+            'SELECT last_mail_at FROM revisions'
+            " WHERE change_id = 'cid' AND revision = 2"
+        ).fetchone()
+        conn.close()
+        assert row[0] == '2026-03-01T00:00:00+00:00'
+
+
+class TestPollerFetchDiscipline:
+    """The poller must fetch the same way every other count writer does."""
+
+    def test_fetch_is_uncached_and_strict(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """A cached mbox can predate the very messages the rt: poll found.
+
+        Counting it would leave message_count unmoved while
+        last_update_check advanced past those messages, losing them.
+        """
+        calls: list[Dict[str, Any]] = []
+
+        def _fake(msgid: str, **kw: Any) -> list[EmailMessage]:
+            calls.append({'msgid': msgid, **kw})
+            return _thread_msgs(3)
+
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(b4, 'get_pi_thread_by_msgid', _fake)
+        msgs = review_tracking._fetch_thread_msgs('v1@x')
+        assert msgs is not None and len(msgs) == 3
+        assert calls == [{'msgid': 'v1@x', 'nocache': True, 'quiet': True}]
+
+    def test_fetch_propagates_cancellation(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        def _cancelled(msgid: str, **kw: Any) -> list[EmailMessage]:
+            raise liblore.OperationCancelledError('Request cancelled')
+
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(b4, 'get_pi_thread_by_msgid', _cancelled)
+        with pytest.raises(liblore.OperationCancelledError):
+            review_tracking._fetch_thread_msgs('v1@x')
+
+    def test_offline_skips_rethreaded_fetch(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Offline must short-circuit before the per-patch requests."""
+        conn = review_tracking.init_db('poll-offline')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x', is_rethreaded=True)
+        _insert_patches(conn, 'cid', 1, ['p1@x', 'p2@x'])
+        conn.close()
+
+        def _boom(series: Dict[str, Any], identifier: str) -> list[EmailMessage]:
+            raise AssertionError('must not fetch while offline')
+
+        monkeypatch.setattr(b4, 'can_network', False)
+        monkeypatch.setattr(b4.review, 'retrieve_series_messages', _boom)
+        result = review_tracking.update_revision_message_counts(
+            'poll-offline', [_poller_series('cid', 2, 'v2@x')]
+        )
+        # Offline is not a failure: no request was issued, so nothing is
+        # reported unreachable.  Counting it would have _cron_update() mail
+        # 'Could not poll N non-tracked revision(s)' after every sweep run
+        # from a machine that happened to be off the network.
+        assert result == {
+            'updated': 0,
+            'new_mail': 0,
+            'errors': 0,
+            'fresh_errors': 0,
+            'polled': 0,
+            'cancelled': 0,
+        }
+
+    def test_cancel_cb_stops_the_sweep(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        conn = review_tracking.init_db('poll-cancel')
+        for rev in (1, 2, 3):
+            review_tracking.add_revision(conn, 'cid', rev, f'v{rev}@x')
+        conn.close()
+        polled: list[int] = []
+
+        def _fake_fetch(
+            identifier: str, conn: Any, change_id: str, rev: Dict[str, Any]
+        ) -> list[EmailMessage]:
+            polled.append(int(rev['revision']))
+            return _thread_msgs(2)
+
+        monkeypatch.setattr(review_tracking, '_fetch_revision_thread_msgs', _fake_fetch)
+        result = review_tracking.update_revision_message_counts(
+            'poll-cancel',
+            [_poller_series('cid', 4, 'v4@x')],
+            cancel_cb=lambda: len(polled) >= 1,
+        )
+        assert polled == [3]
+        assert result['updated'] == 1
+
+    def test_connection_closed_when_a_revision_raises(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """update_all_tracking swallows and continues, so a leak compounds."""
+        conn = review_tracking.init_db('poll-leak')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.close()
+        real_get_db = review_tracking.get_db
+
+        class _ProxyConn:
+            """sqlite3.Connection.close is read-only, so wrap instead."""
+
+            def __init__(self, real: sqlite3.Connection) -> None:
+                self._real = real
+                self.closed = False
+
+            def __getattr__(self, name: str) -> Any:
+                return getattr(self._real, name)
+
+            def close(self) -> None:
+                self.closed = True
+                self._real.close()
+
+        proxies: list[_ProxyConn] = []
+
+        def _tracking_get_db(identifier: str) -> Any:
+            proxy = _ProxyConn(real_get_db(identifier))
+            proxies.append(proxy)
+            return proxy
+
+        def _boom(*a: Any, **kw: Any) -> None:
+            raise sqlite3.OperationalError('database is locked')
+
+        monkeypatch.setattr(review_tracking, 'get_db', _tracking_get_db)
+        monkeypatch.setattr(review_tracking, '_update_one_revision_count', _boom)
+        with pytest.raises(sqlite3.OperationalError):
+            review_tracking.update_revision_message_counts(
+                'poll-leak', [_poller_series('cid', 2, 'v2@x')]
+            )
+        assert proxies and all(p.closed for p in proxies)
+
+    def test_incremental_stamps_per_revision(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """A sweep-wide timestamp double-counts mail arriving during it."""
+        conn = review_tracking.init_db('poll-stamp')
+        for rev in (1, 2):
+            review_tracking.add_revision(conn, 'cid', rev, f'v{rev}@x')
+        conn.close()
+        stamps: list[str] = []
+
+        def _fake_fetch(
+            identifier: str, conn: Any, change_id: str, rev: Dict[str, Any]
+        ) -> list[EmailMessage]:
+            return _thread_msgs(2)
+
+        monkeypatch.setattr(review_tracking, '_fetch_revision_thread_msgs', _fake_fetch)
+        review_tracking.update_revision_message_counts(
+            'poll-stamp', [_poller_series('cid', 3, 'v3@x')]
+        )
+        conn = review_tracking.get_db('poll-stamp')
+        stamps = [
+            r['last_update_check']
+            for r in review_tracking.get_revisions(conn, 'cid')
+            # v3 is the tracked revision, so the poller skips it
+            if r['revision'] != 3
+        ]
+        conn.close()
+        assert len(stamps) == 2
+        assert all(s for s in stamps)
+        assert len(set(stamps)) == 2
+
+
+class TestRevisionSwitchClearsStaleState:
+    """update_series_revision() must not leave the old revision behind."""
+
+    def _seed(self, identifier: str) -> None:
+        conn = review_tracking.init_db(identifier)
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='[PATCH v2 0/2] thing',
+            sender_name='S',
+            sender_email='s@e.com',
+            sent_at='2026-01-01T00:00:00+00:00',
+            message_id='v2@e.com',
+            num_patches=2,
+        )
+        conn.execute(
+            'UPDATE revisions SET message_count = 10, seen_message_count = 10,'
+            " last_update_check = '2026-05-01T00:00:00+00:00',"
+            " last_mail_at = '2026-04-28T00:00:00+00:00'"
+            " WHERE change_id = 'cid'"
+        )
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@e.com')
+        review_tracking.add_revision(conn, 'cid', 3, 'v3@e.com')
+        conn.execute(
+            'UPDATE revisions SET message_count = 7, seen_message_count = 7,'
+            " last_update_check = '2026-06-01T00:00:00+00:00',"
+            " last_mail_at = '2026-05-30T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 3"
+        )
+        conn.commit()
+        conn.close()
+
+    def test_watermark_does_not_survive_the_switch(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """The incoming revision must not inherit the outgoing thread's watermark."""
+        self._seed('switch-watermark')
+        conn = review_tracking.get_db('switch-watermark')
+        review_tracking.update_series_revision(conn, 'cid', 2, 3, 'v3@e.com')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        # v3's count comes from the catalog, so its watermark must too --
+        # not from the series row, where it still described v2.
+        assert revs[3]['message_count'] == 7
+        assert revs[3]['last_update_check'] == '2026-06-01T00:00:00+00:00'
+
+    def test_activity_is_the_threads_not_the_switch(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """A version row reports thread activity, not when the upgrade happened."""
+        self._seed('switch-activity')
+        conn = review_tracking.get_db('switch-activity')
+        review_tracking.update_series_revision(conn, 'cid', 2, 3, 'v3@e.com')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[3]['last_mail_at'] == '2026-05-30T00:00:00+00:00'
+        assert revs[2]['last_mail_at'] == '2026-04-28T00:00:00+00:00'
+
+    def test_rethread_flag_is_repointed(self, tmp_path: pytest.TempPathFactory) -> None:
+        """The flag describes the tracked revision, so it moves with it."""
+        self._seed('switch-rethread')
+        conn = review_tracking.get_db('switch-rethread')
+        conn.execute("UPDATE series SET is_rethreaded = 1 WHERE change_id = 'cid'")
+        conn.commit()
+        review_tracking.update_series_revision(conn, 'cid', 2, 3, 'v3@e.com')
+        row = conn.execute(
+            "SELECT is_rethreaded FROM series WHERE change_id = 'cid'"
+        ).fetchone()
+        assert row[0] == 0
+        # ...and parking must not stamp the stale flag onto the catalog,
+        # where add_revision() promotes but never clears it.
+        review_tracking.update_series_status(conn, 'cid', 'archived', revision=3)
+        crow = conn.execute(
+            'SELECT is_rethreaded FROM revisions'
+            " WHERE change_id = 'cid' AND revision = 3"
+        ).fetchone()
+        conn.close()
+        assert crow[0] == 0
+
+    def test_incoming_counts_are_simply_read(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """A switch cannot mark the incoming version's unread mail read.
+
+        There is no first-sighting to mistake it for: the row the series
+        moves onto already holds whatever the poller learned about it, and
+        the next count write reads that rather than a blank series row.
+        """
+        self._seed('switch-adopt')
+        conn = review_tracking.get_db('switch-adopt')
+        conn.execute(
+            'UPDATE revisions SET seen_message_count = 4'
+            " WHERE change_id = 'cid' AND revision = 3"
+        )
+        conn.commit()
+        review_tracking.update_series_revision(conn, 'cid', 2, 3, 'v3@e.com')
+        # The sweep refetches the tracked thread and finds the same 7.
+        changed = review_tracking.update_message_count_from_msgs(
+            conn, 'cid', 3, _thread_msgs(7)
+        )
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert (revs[3]['message_count'], revs[3]['seen_message_count']) == (7, 4)
+        # Nothing moved, and nothing had to be moved for it to be right.
+        assert changed is False
+
+    def test_refresh_count_reads_the_same_row(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """Opening the thread viewer must not clear the badge either."""
+        self._seed('switch-adopt-refresh')
+        conn = review_tracking.get_db('switch-adopt-refresh')
+        conn.execute(
+            'UPDATE revisions SET seen_message_count = 4'
+            " WHERE change_id = 'cid' AND revision = 3"
+        )
+        conn.commit()
+        review_tracking.update_series_revision(conn, 'cid', 2, 3, 'v3@e.com')
+        conn.close()
+        # 7 is what the row already holds, so there is nothing to write.
+        assert not review_tracking.refresh_message_count(
+            'switch-adopt-refresh', 'cid', 3, 7
+        )
+        conn = review_tracking.get_db('switch-adopt-refresh')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert (revs[3]['message_count'], revs[3]['seen_message_count']) == (7, 4)
+
+
+class TestPollerLeavesOtherLiveRowsAlone:
+    """A second live series row's revision is not the poller's to write."""
+
+    @staticmethod
+    def _two_live_rows(identifier: str) -> None:
+        """One change_id, two non-archived series rows, as rescan leaves them."""
+        conn = review_tracking.init_db(identifier)
+        for rev, msgid in ((2, 'v2@x'), (1, 'v1@x')):
+            review_tracking.add_series_to_db(
+                conn,
+                change_id='cid',
+                revision=rev,
+                subject=f'[PATCH v{rev}] thing',
+                sender_name='S',
+                sender_email='s@e.com',
+                sent_at='2026-01-01T00:00:00+00:00',
+                message_id=msgid,
+                num_patches=1,
+            )
+            review_tracking.add_revision(conn, 'cid', rev, msgid)
+        conn.commit()
+        conn.close()
+
+    def test_another_rows_tracked_revision_is_not_polled(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """update_all_tracking() walks the rows one at a time.
+
+        Excluding only the revision of the dict this call was handed leaves
+        the *other* row's actively tracked revision fair game -- and the
+        poller writes the catalog from a first fetch (seen = count), so that
+        row's unread delta is gone before anything can park it.
+        """
+        self._two_live_rows('poll-otherrow')
+        conn = review_tracking.get_db('poll-otherrow')
+        conn.execute(
+            'UPDATE revisions SET message_count = 20, seen_message_count = 17,'
+            " last_update_check = '2026-01-01T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+        polled: list[int] = []
+
+        def _fetch(identifier: str, conn: Any, change_id: str, rev: Any) -> Any:
+            polled.append(int(rev['revision']))
+            return _thread_msgs(20)
+
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(review_tracking, '_fetch_revision_thread_msgs', _fetch)
+        review_tracking.update_revision_message_counts(
+            'poll-otherrow', [_poller_series('cid', 2, 'v2@x')]
+        )
+        assert polled == []
+
+    def test_the_other_rows_unread_survives(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """The end-to-end damage this once caused: three unread marked read.
+
+        A change_id with two live series rows had one row's unread state
+        overwritten by the other.  Nothing copies read state between rows
+        any more, so the poller simply has to leave a revision another live
+        row tracks to that row.
+        """
+        self._two_live_rows('poll-otherpark')
+        conn = review_tracking.get_db('poll-otherpark')
+        conn.execute(
+            'UPDATE revisions SET message_count = 20, seen_message_count = 17,'
+            " last_update_check = '2026-01-01T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: _thread_msgs(20),
+        )
+        review_tracking.update_revision_message_counts(
+            'poll-otherpark', [_poller_series('cid', 2, 'v2@x')]
+        )
+        conn = review_tracking.get_db('poll-otherpark')
+        review_tracking.update_series_status(conn, 'cid', 'archived', revision=1)
+        row = conn.execute(
+            'SELECT message_count, seen_message_count FROM revisions'
+            " WHERE change_id = 'cid' AND revision = 1"
+        ).fetchone()
+        conn.close()
+        assert (row[0], row[1]) == (20, 17)
+
+
+class TestPollerRoutesOnTheCatalogRow:
+    """The poller reads the row it writes, not the stitched view."""
+
+    def test_counted_but_unwatermarked_keeps_its_badge(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """The v11 migration seeds counts with a NULL watermark; don't clobber."""
+        conn = review_tracking.init_db('poll-nowm')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 4, seen_message_count = 1,'
+            " last_mail_at = '2026-02-02T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+        # No Date: headers, so the fetch cannot improve on last_activity_at.
+        undated = _thread_msgs(6)
+        for msg in undated:
+            del msg['Date']
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: undated,
+        )
+        review_tracking.update_revision_message_counts(
+            'poll-nowm', [_poller_series('cid', 2, 'v2@x')]
+        )
+        conn = review_tracking.get_db('poll-nowm')
+        row = conn.execute(
+            'SELECT message_count, seen_message_count, last_mail_at'
+            " FROM revisions WHERE change_id = 'cid' AND revision = 1"
+        ).fetchone()
+        conn.close()
+        # 3 unread before, 5 after -- not "all read".
+        assert (row['message_count'], row['seen_message_count']) == (6, 1)
+        assert row['last_mail_at'] == '2026-02-02T00:00:00+00:00'
+
+    def test_single_patch_rethread_uses_the_recorded_thread(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """One recorded patch is not enough to reassemble from."""
+        conn = review_tracking.init_db('poll-rt1')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x', is_rethreaded=True)
+        conn.execute(
+            'INSERT INTO series_patches (change_id, revision, position, message_id)'
+            " VALUES ('cid', 1, 1, 'p1@x')"
+        )
+        conn.commit()
+        conn.close()
+        queried: list[str] = []
+
+        def _thread(msgid: str) -> list[EmailMessage]:
+            queried.append(msgid)
+            return _thread_msgs(3)
+
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(review_tracking, '_fetch_thread_msgs', _thread)
+        monkeypatch.setattr(
+            b4.review,
+            'retrieve_series_messages',
+            lambda series, identifier: pytest.fail('must not reassemble'),
+        )
+        review_tracking.update_revision_message_counts(
+            'poll-rt1', [_poller_series('cid', 2, 'v2@x')]
+        )
+        assert queried == ['v1@x']
+
+
+class TestPollCapFairness:
+    """The cap must not permanently hide the oldest versions."""
+
+    def _seed(self, identifier: str, revs: int) -> None:
+        conn = review_tracking.init_db(identifier)
+        for rev in range(1, revs + 1):
+            review_tracking.add_revision(conn, 'cid', rev, f'v{rev}@x')
+        conn.close()
+
+    def test_never_counted_revisions_are_polled_first(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """A newest-first cap left v1/v2 of a v6 series at '-' forever."""
+        self._seed('cap-fair', 5)
+        conn = review_tracking.get_db('cap-fair')
+        # v3..v5 already counted; v1 and v2 never were.
+        conn.execute(
+            'UPDATE revisions SET message_count = 2, seen_message_count = 2,'
+            " last_update_check = '2026-06-01T00:00:00+00:00'"
+            ' WHERE change_id = ? AND revision >= 3',
+            ('cid',),
+        )
+        conn.commit()
+        conn.close()
+        fetched: list[int] = []
+
+        def _full(identifier: str, conn: Any, change_id: str, rev: Any) -> Any:
+            fetched.append(int(rev['revision']))
+            return _thread_msgs(2)
+
+        monkeypatch.setattr(review_tracking, '_fetch_revision_thread_msgs', _full)
+        review_tracking.update_revision_message_counts(
+            'cap-fair',
+            [_poller_series('cid', 6, 'v6@x')],
+            max_revisions_per_series=2,
+        )
+        assert sorted(fetched) == [1, 2]
+
+    def test_a_failed_fetch_does_not_spend_the_budget(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """One unreachable revision must not starve the ones behind it."""
+        self._seed('cap-fail', 3)
+        attempted: list[int] = []
+
+        def _full(identifier: str, conn: Any, change_id: str, rev: Any) -> Any:
+            revision = int(rev['revision'])
+            attempted.append(revision)
+            # v3 is unreachable, the rest are fine.
+            return None if revision == 3 else _thread_msgs(2)
+
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(review_tracking, '_fetch_revision_thread_msgs', _full)
+        result = review_tracking.update_revision_message_counts(
+            'cap-fail',
+            [_poller_series('cid', 4, 'v4@x')],
+            max_revisions_per_series=2,
+        )
+        assert result['errors'] == 1
+        assert result['updated'] == 2
+        assert attempted == [3, 2, 1]
+
+    def test_two_failures_in_a_row_stop_the_series(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Offline means every revision fails; don't try the whole catalog."""
+        self._seed('cap-offline', 6)
+        attempted: list[int] = []
+
+        def _full(identifier: str, conn: Any, change_id: str, rev: Any) -> Any:
+            attempted.append(int(rev['revision']))
+            return None
+
+        monkeypatch.setattr(review_tracking, '_fetch_revision_thread_msgs', _full)
+        review_tracking.update_revision_message_counts(
+            'cap-offline',
+            [_poller_series('cid', 7, 'v7@x')],
+            max_revisions_per_series=4,
+        )
+        assert len(attempted) == 2
+
+    def test_a_dead_revision_does_not_starve_the_rotation(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Two permanently unreachable revisions used to stop every sweep.
+
+        They stayed uncounted, so they sorted to the front for ever, and
+        two failures in a row abandoned the series before anything else
+        was reached -- including the recent versions late replies land on.
+        """
+        monkeypatch.setattr(b4, 'can_network', True)
+        self._seed('cap-dead', 5)
+        conn = review_tracking.get_db('cap-dead')
+        conn.execute(
+            'UPDATE revisions SET message_count = 2, seen_message_count = 2,'
+            " last_update_check = '2026-06-01T00:00:00+00:00'"
+            ' WHERE change_id = ? AND revision >= 4',
+            ('cid',),
+        )
+        conn.commit()
+        conn.close()
+
+        def _full(identifier: str, conn: Any, change_id: str, rev: Any) -> Any:
+            # v1 and v2 are gone from the archive for good.
+            return None if int(rev['revision']) in (1, 2) else _thread_msgs(3)
+
+        monkeypatch.setattr(review_tracking, '_fetch_revision_thread_msgs', _full)
+        for _ in range(3):
+            review_tracking.update_revision_message_counts(
+                'cap-dead',
+                [_poller_series('cid', 6, 'v6@x')],
+                max_revisions_per_series=2,
+            )
+        conn = review_tracking.get_db('cap-dead')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        # The reachable versions were reached despite the two dead ones.
+        assert revs[4]['message_count'] == 3
+        assert revs[5]['message_count'] == 3
+
+    def test_named_revisions_are_not_abandoned_after_two_failures(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """discover_older_revisions passes only_revisions with no cap.
+
+        It does that precisely so every version it just recorded gets
+        counted; giving up after two dead message-ids -- which is exactly
+        what a backward lore search turns up -- reintroduces the starvation
+        the missing cap was avoiding.
+        """
+        self._seed('cap-named', 4)
+        fetched: list[int] = []
+
+        def _full(identifier: str, conn: Any, change_id: str, rev: Any) -> Any:
+            revision = int(rev['revision'])
+            fetched.append(revision)
+            return None if revision in (3, 4) else _thread_msgs(3)
+
+        monkeypatch.setattr(review_tracking, '_fetch_revision_thread_msgs', _full)
+        review_tracking.update_revision_message_counts(
+            'cap-named',
+            [_poller_series('cid', 5, 'v5@x')],
+            only_revisions={1, 2, 3, 4},
+        )
+        conn = review_tracking.get_db('cap-named')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert fetched == [4, 3, 2, 1]
+        assert revs[1]['message_count'] == 3
+        assert revs[2]['message_count'] == 3
+
+    def test_counted_revisions_come_back_around(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """A newest-first cap watched only the newest few, for ever."""
+        self._seed('cap-rotate', 6)
+        conn = review_tracking.get_db('cap-rotate')
+        conn.execute(
+            'UPDATE revisions SET message_count = 2, seen_message_count = 2,'
+            " last_update_check = '2026-06-01T00:00:00+00:00'",
+        )
+        conn.commit()
+        conn.close()
+        attempted: list[int] = []
+
+        def _full(identifier: str, conn: Any, change_id: str, rev: Any) -> Any:
+            attempted.append(int(rev['revision']))
+            return _thread_msgs(2)
+
+        monkeypatch.setattr(review_tracking, '_fetch_revision_thread_msgs', _full)
+        for _ in range(3):
+            review_tracking.update_revision_message_counts(
+                'cap-rotate',
+                [_poller_series('cid', 7, 'v7@x')],
+                max_revisions_per_series=2,
+            )
+        # Six sweeps' worth of budget covered all six versions, not the
+        # same two over and over.
+        assert sorted(attempted) == [1, 2, 3, 4, 5, 6]
+
+
+class TestSeenSyncFallsBackToCatalog:
+    def test_uncounted_series_row_defers_to_the_catalog(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """A NULL series count is not "nothing to do".
+
+        The displayed badge came from the catalog, so that is where the
+        sync has to land.
+        """
+        conn = review_tracking.init_db('seen-fallback')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=3,
+            subject='[PATCH v3] thing',
+            sender_name='S',
+            sender_email='s@e.com',
+            sent_at='2026-01-01T00:00:00+00:00',
+            message_id='v3@x',
+            num_patches=1,
+        )
+        review_tracking.add_revision(conn, 'cid', 3, 'v3@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 8, seen_message_count = 8'
+            " WHERE change_id = 'cid' AND revision = 3"
+        )
+        conn.commit()
+        conn.close()
+        assert review_tracking.sync_seen_from_unseen_count('seen-fallback', 'cid', 3, 3)
+        conn = review_tracking.get_db('seen-fallback')
+        row = conn.execute(
+            'SELECT seen_message_count FROM revisions'
+            " WHERE change_id = 'cid' AND revision = 3"
+        ).fetchone()
+        conn.close()
+        assert row[0] == 5
+
+
+class TestRethreadFlagStitching:
+    """`series.is_rethreaded` is never NULL, so it cannot be COALESCEd over."""
+
+    @staticmethod
+    def _seed(identifier: str) -> None:
+        conn = review_tracking.init_db(identifier)
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='[PATCH v2] thing',
+            sender_name='S',
+            sender_email='s@e.com',
+            sent_at='2026-01-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=2,
+        )
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x', is_rethreaded=True)
+        _insert_patches(conn, 'cid', 2, ['p1@x', 'p2@x'])
+        conn.commit()
+        conn.close()
+
+    def test_catalog_rethread_survives_a_zeroed_series_row(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """The column is INTEGER DEFAULT 0, so a COALESCE always picks it.
+
+        A series row that lost the flag (the upgrade path used to write the
+        default) would then permanently mask the catalog's 1.
+        """
+        self._seed('rt-stitch')
+        conn = review_tracking.get_db('rt-stitch')
+        row = conn.execute(
+            "SELECT is_rethreaded FROM series WHERE change_id = 'cid'"
+        ).fetchone()
+        conn.close()
+        # Precondition: the series row really does hold a non-NULL 0.
+        assert row[0] == 0
+
+        conn = review_tracking.get_db('rt-stitch')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[2]['is_rethreaded']
+
+    def test_known_revisions_keeps_the_patch_list(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """build_known_revisions() gates the portable patch list on the flag.
+
+        update_series_tracking() rewrites `known-revisions` on every sweep,
+        so a dropped flag actively erases a rethreaded revision from the
+        branch -- and it cannot be re-derived from lore.
+        """
+        self._seed('rt-known')
+        conn = review_tracking.get_db('rt-known')
+        known = review_tracking.build_known_revisions(conn, 'cid')
+        conn.close()
+        entry = next(e for e in known if e['revision'] == 2)
+        assert entry.get('is-rethreaded') is True
+        assert [p['message-id'] for p in entry['patches']] == ['p1@x', 'p2@x']
+
+
+class TestShrinkIsRecorded:
+    def test_a_stale_high_watermark_does_not_eat_the_badge(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """10/10 stored, real thread 8 -> two replies -> the badge shows.
+
+        Threads do shrink (dedup variation, mail removed from the
+        archive), and a writer that refuses any total at or below the
+        stored one turns 10 into a watermark: the corrected 8 and the
+        subsequent genuine 10 are both refused, the badge never lights,
+        and the one repair path runs only when the maintainer opens the
+        thread the missing badge was meant to point at.
+        """
+        conn = review_tracking.init_db('shrink-badge')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 10, seen_message_count = 10,'
+            " last_update_check = '2026-01-01T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+
+        size = [8]
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: _thread_msgs(size[0]),
+        )
+        review_tracking.update_revision_message_counts(
+            'shrink-badge', [_poller_series('cid', 2, 'v2@x')]
+        )
+        conn = review_tracking.get_db('shrink-badge')
+        row = conn.execute(
+            'SELECT message_count, seen_message_count FROM revisions'
+            " WHERE change_id = 'cid' AND revision = 1"
+        ).fetchone()
+        # The shrink is recorded and seen capped with it: nothing unread.
+        assert (row[0], row[1]) == (8, 8)
+        # Age the check stamp out of the minimum-interval gate.
+        conn.execute(
+            "UPDATE revisions SET last_update_check = '2026-01-01T00:00:00+00:00'"
+        )
+        conn.commit()
+        conn.close()
+
+        size[0] = 10
+        review_tracking.update_revision_message_counts(
+            'shrink-badge', [_poller_series('cid', 2, 'v2@x')]
+        )
+        conn = review_tracking.get_db('shrink-badge')
+        row = conn.execute(
+            'SELECT message_count, seen_message_count FROM revisions'
+            " WHERE change_id = 'cid' AND revision = 1"
+        ).fetchone()
+        conn.close()
+        # The two genuine replies badge instead of vanishing under the
+        # old high watermark.
+        assert (row[0], row[1]) == (10, 8)
+
+    def test_a_genuine_growth_is_still_taken(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Growth stores the count and leaves seen for the badge."""
+        conn = review_tracking.init_db('short-grow')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 4, seen_message_count = 4'
+            " WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: _thread_msgs(6),
+        )
+        review_tracking.update_revision_message_counts(
+            'short-grow', [_poller_series('cid', 2, 'v2@x')]
+        )
+        conn = review_tracking.get_db('short-grow')
+        row = conn.execute(
+            'SELECT message_count, seen_message_count FROM revisions'
+            " WHERE change_id = 'cid' AND revision = 1"
+        ).fetchone()
+        conn.close()
+        assert (row[0], row[1]) == (6, 4)
+
+
+class TestTrackedRevisionActivityKeepsMoving:
+    def test_the_catalog_date_follows_the_tracked_thread(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """Per-revision reads prefer the catalog, and the poller skips the
+        tracked revision -- so without a mirror a version's date freezes at
+        whatever poll it last got as an older version."""
+        conn = review_tracking.init_db('act-mirror')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=3,
+            subject='[PATCH v3] thing',
+            sender_name='S',
+            sender_email='s@e.com',
+            sent_at='2026-01-01T00:00:00+00:00',
+            message_id='v3@x',
+            num_patches=1,
+        )
+        review_tracking.add_revision(conn, 'cid', 3, 'v3@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 2, seen_message_count = 2,'
+            " last_mail_at = '2026-02-01T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 3"
+        )
+        conn.commit()
+        conn.close()
+        # get_db(), not the init_db() handle: update_message_count_from_msgs
+        # indexes rows by name and only get_db() sets the Row factory.
+        conn = review_tracking.get_db('act-mirror')
+        review_tracking.update_message_count_from_msgs(conn, 'cid', 3, _thread_msgs(4))
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        # _thread_msgs dates run 01..04 Jul 2026, so the newest wins.
+        assert revs[3]['last_mail_at'].startswith('2026-07-04')
+
+    def test_a_maintainer_action_does_not_reach_the_catalog(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """Only a real Date: header moves the column."""
+        conn = review_tracking.init_db('act-nomaint')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=3,
+            subject='[PATCH v3] thing',
+            sender_name='S',
+            sender_email='s@e.com',
+            sent_at='2026-01-01T00:00:00+00:00',
+            message_id='v3@x',
+            num_patches=1,
+        )
+        review_tracking.add_revision(conn, 'cid', 3, 'v3@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 2, seen_message_count = 2,'
+            " last_mail_at = '2026-02-01T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 3"
+        )
+        conn.commit()
+        review_tracking.update_series_status(conn, 'cid', 'waiting', revision=3)
+        row = conn.execute(
+            'SELECT last_mail_at FROM revisions'
+            " WHERE change_id = 'cid' AND revision = 3"
+        ).fetchone()
+        conn.close()
+        assert row[0] == '2026-02-01T00:00:00+00:00'
+
+
+class TestPrunedThreadBlobIsReCached:
+    def test_a_gc_d_blob_is_replaced_on_a_quiet_poll(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Thread blobs are unreferenced objects; git gc may take one.
+
+        A settled old version's count never moves again, so the quiet path
+        is its only chance -- and it used to treat the dead SHA still in the
+        row as proof the thread was cached.
+        """
+        conn = review_tracking.init_db('blob-gc')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 2, seen_message_count = 2,'
+            " thread_blob = 'deadbeef' WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+        stored: list[int] = []
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: _thread_msgs(2),
+        )
+        monkeypatch.setattr(
+            review_tracking, '_thread_blob_exists', lambda topdir, sha: False
+        )
+        monkeypatch.setattr(
+            review_tracking,
+            'store_revision_thread_blob',
+            lambda conn, topdir, change_id, revision, msgs: stored.append(len(msgs)),
+        )
+        review_tracking.update_revision_message_counts(
+            'blob-gc', [_poller_series('cid', 2, 'v2@x')], topdir='/nonexistent'
+        )
+        assert stored == [2]
+
+    def test_a_blob_holding_at_least_as_much_is_left_alone(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        conn = review_tracking.init_db('blob-live')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 2, seen_message_count = 2,'
+            " thread_blob = 'deadbeef' WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+        stored: list[int] = []
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: _thread_msgs(2),
+        )
+        monkeypatch.setattr(
+            review_tracking, '_thread_blob_exists', lambda topdir, sha: True
+        )
+        monkeypatch.setattr(
+            review_tracking,
+            'store_revision_thread_blob',
+            lambda conn, topdir, change_id, revision, msgs: stored.append(len(msgs)),
+        )
+        review_tracking.update_revision_message_counts(
+            'blob-live', [_poller_series('cid', 2, 'v2@x')], topdir='/nonexistent'
+        )
+        assert stored == []
+
+    def test_the_quiet_path_does_not_read_the_blob_back(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Existence, not content: the thread is the one already stored.
+
+        Every cataloged revision comes through here on every sweep, so
+        reading and re-parsing each one's whole mbox to answer "no change"
+        is a cost the rotation pays for nothing.
+        """
+        conn = review_tracking.init_db('blob-quiet-read')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 2, seen_message_count = 2,'
+            " thread_blob = 'deadbeef' WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: _thread_msgs(2),
+        )
+        monkeypatch.setattr(
+            review_tracking, '_thread_blob_exists', lambda topdir, sha: True
+        )
+        monkeypatch.setattr(
+            review_tracking,
+            'get_thread_mbox',
+            lambda topdir, sha: pytest.fail('quiet poll must not read the blob'),
+        )
+        review_tracking.update_revision_message_counts(
+            'blob-quiet-read', [_poller_series('cid', 2, 'v2@x')], topdir='/nonexistent'
+        )
+
+    def test_a_shrink_still_restores_a_pruned_blob(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """The short-fetch path has the messages in hand; cache them.
+
+        A revision whose thread ends up permanently shorter than the
+        stored watermark otherwise never got its pruned blob back, and
+        every range-diff against it refetched from lore forever.
+        """
+        conn = review_tracking.init_db('blob-shrink-gc')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 10, seen_message_count = 10,'
+            " thread_blob = 'deadbeef' WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: _thread_msgs(8),
+        )
+        stored: list[int] = []
+        monkeypatch.setattr(
+            review_tracking,
+            'store_revision_thread_blob',
+            lambda conn, topdir, change_id, revision, msgs: stored.append(len(msgs)),
+        )
+        review_tracking.update_revision_message_counts(
+            'blob-shrink-gc', [_poller_series('cid', 2, 'v2@x')], topdir='/nonexistent'
+        )
+        assert stored == [8]
+
+
+class TestSeenWritersStayConsistent:
+    @staticmethod
+    def _seed(identifier: str, series_count: Optional[int], cat_count: int) -> None:
+        conn = review_tracking.init_db(identifier)
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='[PATCH v2] thing',
+            sender_name='S',
+            sender_email='s@e.com',
+            sent_at='2026-01-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=1,
+        )
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+        if series_count is not None:
+            conn.execute(
+                'UPDATE revisions SET message_count = ?, seen_message_count = ?'
+                " WHERE change_id = 'cid' AND revision = 2",
+                (series_count, series_count - 3),
+            )
+        conn.execute(
+            'UPDATE revisions SET message_count = ?, seen_message_count = ?'
+            " WHERE change_id = 'cid' AND revision = 2",
+            (cat_count, cat_count),
+        )
+        conn.commit()
+        conn.close()
+
+    def test_sync_writes_the_single_copy(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """There is no second copy to go stale behind this one."""
+        self._seed('seen-mirror', series_count=10, cat_count=10)
+        assert review_tracking.sync_seen_from_unseen_count('seen-mirror', 'cid', 2, 2)
+        conn = review_tracking.get_db('seen-mirror')
+        row = conn.execute(
+            'SELECT message_count, seen_message_count FROM revisions'
+            " WHERE change_id = 'cid' AND revision = 2"
+        ).fetchone()
+        conn.close()
+        assert (row[0], row[1]) == (10, 8)
+
+    def test_mark_seen_skips_a_row_with_no_count(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """A seen count against a NULL total is a badge with no basis.
+
+        There is no longer a second row for it to shadow, but recording it
+        would still leave seen > total the moment a count did arrive.
+        """
+        conn = review_tracking.init_db('seen-nullcount')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=1,
+        )
+        conn.commit()
+        review_tracking.mark_all_messages_seen(conn, 'cid', 2)
+        row = conn.execute(
+            'SELECT message_count, seen_message_count FROM revisions'
+            " WHERE change_id = 'cid' AND revision = 2"
+        ).fetchone()
+        conn.close()
+        assert row[0] is None
+        assert row[1] is None
+
+
+class TestMigrationDeclinesWhatItCannotCarry:
+    """A series table too degenerate to backfill from keeps its columns.
+
+    The read-state move drops the `series` copies only inside the backfill
+    guard: dropping a copy that was never carried across would just lose
+    it.  The branch_sha move sits outside that guard because it needs
+    nothing from `series` but the two columns every version of it has had.
+    """
+
+    @staticmethod
+    def _degenerate_v1_db(identifier: str) -> None:
+        import sqlite3 as _sqlite3
+
+        raw = _sqlite3.connect(review_tracking.get_db_path(identifier))
+        raw.executescript("""
+            CREATE TABLE schema_version (version INTEGER PRIMARY KEY);
+            CREATE TABLE series (
+                track_id INTEGER PRIMARY KEY,
+                change_id TEXT NOT NULL,
+                revision INTEGER NOT NULL,
+                status TEXT DEFAULT 'new',
+                UNIQUE (change_id, revision)
+            );
+        """)
+        raw.execute('INSERT INTO schema_version (version) VALUES (1)')
+        raw.commit()
+        raw.close()
+
+    def test_read_state_survives_a_backfill_it_cannot_run(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        self._degenerate_v1_db('mig-degenerate')
+        conn = review_tracking.get_db('mig-degenerate')
+        series_cols = {row[1] for row in conn.execute('PRAGMA table_info(series)')}
+        rev_cols = {row[1] for row in conn.execute('PRAGMA table_info(revisions)')}
+        version = conn.execute('SELECT version FROM schema_version').fetchone()[0]
+        conn.close()
+        read_state = {'message_count', 'seen_message_count', 'last_update_check'}
+        # The catalog gains them either way ...
+        assert read_state <= rev_cols
+        # ... and `series` keeps its own, because there was nothing to copy:
+        # this table never had the identity columns the backfill selects.
+        assert read_state <= series_cols
+        assert version == review_tracking.SCHEMA_VERSION
+
+    def test_branch_sha_moves_even_so(self, tmp_path: pytest.TempPathFactory) -> None:
+        self._degenerate_v1_db('mig-degenerate-sha')
+        conn = review_tracking.get_db('mig-degenerate-sha')
+        series_cols = {row[1] for row in conn.execute('PRAGMA table_info(series)')}
+        chg_cols = {row[1] for row in conn.execute('PRAGMA table_info(changes)')}
+        conn.close()
+        assert 'branch_sha' not in series_cols
+        assert 'branch_sha' in chg_cols

-- 
2.53.0


^ permalink raw reply related	[flat|nested] 26+ messages in thread

* [PATCH RFC v2 12/25] review-tui: poll every revision on u/U updates
  2026-08-12 21:46 [PATCH RFC v2 00/25] review: track and browse every version of a tracked series Christian Brauner
                   ` (10 preceding siblings ...)
  2026-08-12 21:46 ` [PATCH RFC v2 11/25] review: test per-revision message tracking Christian Brauner
@ 2026-08-12 21:46 ` Christian Brauner
  2026-08-12 21:46 ` [PATCH RFC v2 13/25] review: test the per-revision poll sweep Christian Brauner
                   ` (12 subsequent siblings)
  24 siblings, 0 replies; 26+ messages in thread
From: Christian Brauner @ 2026-08-12 21:46 UTC (permalink / raw)
  To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Christian Brauner (Amutable)

Hook update_revision_message_counts() into the per-series update loop so
every known version of a tracked series gets new-mail detection, not just
the tracked revision.  Revisions discovered by update_series_tracking()
moments earlier are picked up in the same pass, because the poller
re-reads the catalog.

A series whose branch is busy is still polled.  Only the branch is left
alone, and the ref catches up on a later sweep, while the poll writes DB
rows and a loose blob and moves no ref.  A series under review is
normally the checked-out branch, so exempting it would mute exactly the
series being worked on.  Its tracked revision's counts keep landing too,
with the tracking-commit save withheld: the thread snapshot behind
seen_bump moves onto the revision's catalog row so it keeps advancing
while the ref is frozen, and the skip is reported only for statuses whose
branch the sweep would have written.

Quiet old versions are re-polled on a schedule keyed to how recently
their thread saw mail.  An explicit u/U bypasses that minimum-age skip,
since a recently checked version silently withheld reads as the update
not working.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 src/b4/review/_review.py           | 184 ++++++++++++++++++++++++++++++-------
 src/b4/review/tracking.py          | 154 ++++++++++++++++++++++++++++---
 src/b4/review_tui/_common.py       |   4 +-
 src/b4/review_tui/_modals.py       |   8 ++
 src/b4/review_tui/_review_app.py   |   2 +-
 src/b4/review_tui/_tracking_app.py |  16 +++-
 src/tests/test_review_tracking.py  |  20 ++--
 7 files changed, 331 insertions(+), 57 deletions(-)

diff --git a/src/b4/review/_review.py b/src/b4/review/_review.py
index f566dbfd..652f21a5 100644
--- a/src/b4/review/_review.py
+++ b/src/b4/review/_review.py
@@ -34,6 +34,10 @@ logger = b4.logger
 REVIEW_MAGIC_MARKER = '--- b4-review-tracking ---'
 REVIEW_BRANCH_PREFIX = 'b4/review/'
 COMMIT_MESSAGE_PATH = ':message'
+# Statuses whose review branch the update sweep rewrites (trailer
+# follow-ups, tracking-commit refreshes); every other status leaves the
+# branch strictly alone.
+BRANCH_UPDATE_STATUSES = ('reviewing', 'replied', 'partial', 'waiting')
 # Synthesized into the cover text when the author sent no cover letter;
 # consumers check for it to detect cover-letter absence.
 NO_COVER_NOTE = 'NOTE: No cover letter provided by the author.'
@@ -1909,7 +1913,7 @@ def _integrate_followup_inline_comments(
     Returns True if any comments were integrated.
     """
     series = tracking['series']
-    blob_sha = series.get('thread-blob', '')
+    blob_sha = b4.review.tracking.resolve_thread_blob(topdir, series)
     if not blob_sha:
         return False
 
@@ -2392,23 +2396,46 @@ def _own_message_entries(
     return entries
 
 
-def _prev_thread_msgids(topdir: str, change_id: str) -> Optional[Set[str]]:
+def _prev_thread_msgids(
+    topdir: str, identifier: str, change_id: str, revision: int
+) -> Optional[Set[str]]:
     """Msgids from the previously stored thread blob, or None if unavailable.
 
     None means "no reliable previous snapshot" — callers must treat that
     as "cannot tell which messages are new" and skip any accounting that
     depends on it.
+
+    The revision's catalog row is the authoritative snapshot: it advances
+    on every counted fetch, including sweeps that must not touch the
+    tracking ref because the branch is checked out.  The tracking commit's
+    blob covers only rows from before the catalog copy existed —
+    preferring it while the ref was frozen re-counted the same messages
+    as new on every sweep.  A recorded catalog blob that no longer reads
+    back (git gc pruned it) is no snapshot at all, not a fallback case.
     """
-    branch = REVIEW_BRANCH_PREFIX + change_id
-    if not b4.git_branch_exists(topdir, branch):
-        return None
+    blob_sha = ''
     try:
-        _, tracking = load_tracking(topdir, branch)
-    except (SystemExit, Exception):
+        conn = b4.review.tracking.get_db(identifier)
+        try:
+            blob_sha = (
+                b4.review.tracking.get_revision_thread_blob(conn, change_id, revision)
+                or ''
+            )
+        finally:
+            conn.close()
+    except Exception:
         return None
-    blob_sha = tracking.get('series', {}).get('thread-blob', '')
     if not blob_sha:
-        return None
+        branch = REVIEW_BRANCH_PREFIX + change_id
+        if not b4.git_branch_exists(topdir, branch):
+            return None
+        try:
+            _, tracking = load_tracking(topdir, branch)
+        except (SystemExit, Exception):
+            return None
+        blob_sha = tracking.get('series', {}).get('thread-blob', '')
+        if not blob_sha:
+            return None
     mbox_bytes = b4.review.tracking.get_thread_mbox(topdir, blob_sha)
     if not mbox_bytes:
         return None
@@ -2548,22 +2575,21 @@ def update_series_tracking(
     # as a side effect of updating.  Do not re-add auto-promotion here.
 
     # Update follow-up trailers if the series has a review branch.  A
-    # branch that is checked out (in any worktree) is left strictly
-    # alone: the maintainer is working on it right there, possibly
-    # mid-rebase, and rewriting its tip under them risks corrupting
-    # their work.  The database updates above have already happened;
-    # the tracking commit catches up on the next sweep after the
-    # branch is no longer checked out.
+    # branch whose worktree has a git operation in flight is left strictly
+    # alone: the maintainer is mid-am or mid-rebase right there, and that
+    # state names the tip this would replace.  The database updates above
+    # have already happened; the tracking commit catches up on the next
+    # sweep that finds the worktree free.
     branch = f'b4/review/{change_id}'
-    update_branch = bool(topdir) and status in (
-        'reviewing',
-        'replied',
-        'partial',
-        'waiting',
-    )
-    if update_branch and topdir and b4.git_branch_checked_out(topdir, branch):
-        logger.debug('%s is checked out, leaving the branch alone', branch)
-        result['checked_out'] = True
+    # Asked only for the statuses whose branch this would write, and only
+    # to *report* the skip -- save_tracking_ref declines on its own, so
+    # nothing below depends on getting this right.  Every other status
+    # short-circuits before the lookup, which is what keeps a sweep from
+    # spending a `git worktree list` per series to learn nothing.
+    update_branch = bool(topdir) and status in BRANCH_UPDATE_STATUSES
+    if update_branch and topdir and b4.git_worktree_busy(topdir, branch):
+        logger.debug('%s is mid-operation, leaving the branch alone', branch)
+        result['branch_busy'] = True
         update_branch = False
     if update_branch and topdir:
         wantver = current_rev
@@ -2665,7 +2691,9 @@ def update_series_tracking(
                 if own_entries:
                     messages.set_flags_bulk(mconn, own_entries, 'Seen')
             if topdir:
-                prev_msgids = _prev_thread_msgids(topdir, change_id)
+                prev_msgids = _prev_thread_msgids(
+                    topdir, identifier, change_id, current_rev
+                )
                 if prev_msgids is not None:
                     new_msgids = []
                     for msg in thread_msgs:
@@ -2725,6 +2753,7 @@ def update_all_tracking(
     series_list: Optional[List[Dict[str, Any]]] = None,
     progress_cb: Optional[Callable[[int, int, str], None]] = None,
     cancel_cb: Optional[Callable[[], bool]] = None,
+    force_revision_poll: bool = False,
 ) -> Dict[str, Any]:
     """Rescan branches, then fetch threads and update all tracked series.
 
@@ -2737,10 +2766,16 @@ def update_all_tracking(
     are updated.  *progress_cb*, when provided, is called as
     ``progress_cb(completed, total, subject)`` around each series.
     *cancel_cb* is polled between series; returning True stops the
-    sweep.
+    sweep.  *force_revision_poll* makes the per-revision poller ignore
+    its minimum-age skip -- set for a user-initiated refresh, where a
+    recently checked version being skipped silently reads as the update
+    not working; scheduled sweeps leave old quiet versions on their
+    schedule.
 
     Returns a summary dict with keys: series_checked, series_updated,
-    errors, gone, followup_updated, error_details, cancelled.
+    errors, gone, followup_updated, revision_counts_updated,
+    revision_errors, revision_fresh_errors, revision_polled,
+    error_details, cancelled.
     """
     result: Dict[str, Any] = {
         'series_checked': 0,
@@ -2748,7 +2783,11 @@ def update_all_tracking(
         'errors': 0,
         'gone': 0,
         'followup_updated': 0,
-        'checked_out_skipped': 0,
+        'revision_counts_updated': 0,
+        'revision_errors': 0,
+        'revision_fresh_errors': 0,
+        'revision_polled': 0,
+        'branch_busy_skipped': 0,
         'error_details': [],
         'cancelled': False,
     }
@@ -2796,14 +2835,73 @@ def update_all_tracking(
                 result['errors'] += 1
                 submitter = series.get('sender_name', 'unknown')
                 result['error_details'].append((submitter, r['error']))
-            if r.get('checked_out'):
-                result['checked_out_skipped'] += 1
             if r.get('counts_updated'):
                 result['followup_updated'] += 1
 
+            if r.get('branch_busy'):
+                # Only the branch was left alone.  The per-revision poll
+                # below writes DB rows and a loose blob, moves no ref, and
+                # a series under review is normally the checked-out branch
+                # -- skipping it here is what stopped late replies to old
+                # versions being noticed at all.
+                result['branch_busy_skipped'] += 1
+
             if progress_cb:
                 progress_cb(i + 1, total, subject)
 
+        # Poll non-tracked revisions for new mail; revisions discovered by
+        # update_series_tracking moments ago are picked up because the
+        # poller re-reads the catalog.  Capped per series: this is one lore
+        # round-trip per revision on top of the per-series fetch, and late
+        # replies land on recent versions.
+        #
+        # One call for the whole list, which is what the signature takes.
+        # Handing it a series at a time reopened the database for every
+        # entry, and scoped its own offline short-circuit to a single
+        # series so a sweep run off the network never stopped early.
+        if not result['cancelled']:
+            # The bar reached N/N when the loop above finished, and the
+            # poll below can be minutes of lore traffic; keep feeding it or
+            # the display reads as hung.  Held at N/N: the series it
+            # counts are all done, and walking the bar backwards through a
+            # second pass over the same list read as a restarted sweep.
+            def _poll_status(subject: str) -> None:
+                if progress_cb:
+                    progress_cb(total, total, f'Polling earlier revisions: {subject}')
+
+            try:
+                rc = b4.review.tracking.update_revision_message_counts(
+                    identifier,
+                    series_list,
+                    topdir=topdir,
+                    max_revisions_per_series=b4.review.tracking.REVISION_POLL_LIMIT,
+                    cancel_cb=cancel_cb,
+                    status_cb=_poll_status if progress_cb else None,
+                    force=force_revision_poll,
+                )
+                result['revision_counts_updated'] += rc.get('new_mail', 0)
+                # Counted separately from result['errors'], which drives
+                # the per-series error report: a revision that will not
+                # fetch is not a series that failed to update.  Without
+                # it a poller that never works (a bad linkmask, 404ing
+                # message-ids) is indistinguishable from a quiet list.
+                result['revision_errors'] += rc.get('errors', 0)
+                result['revision_fresh_errors'] += rc.get('fresh_errors', 0)
+                result['revision_polled'] += rc.get('polled', 0)
+                if rc.get('cancelled'):
+                    result['cancelled'] = True
+            except liblore.OperationCancelledError:
+                result['cancelled'] = True
+            except Exception as ex:
+                # Counted, not just logged.  A poll that raises has failed
+                # exactly as surely as one whose fetch came back empty,
+                # and at debug level the whole feature can break -- a
+                # locked database, a regression in the poll path -- while
+                # every sweep goes on reporting a clean run.
+                result['revision_errors'] += 1
+                result['revision_fresh_errors'] += 1
+                logger.debug('Per-revision count update failed: %s', ex)
+
     return result
 
 
@@ -2892,11 +2990,33 @@ def _cron_update(identifier: str, topdir: Optional[str]) -> None:
         )
     else:
         logger.debug('Checked %s series, no updates', result['series_checked'])
-    if result.get('checked_out_skipped'):
+    if result.get('revision_counts_updated'):
+        logger.info(
+            'New mail on %s non-tracked revision(s)',
+            result['revision_counts_updated'],
+        )
+    if result.get('revision_errors'):
+        # Out of error_details (not a per-series failure).  Warned -- and
+        # thus turned into cron mail -- only when the whole poll came back
+        # empty AND something failed for the first time.  A permanently
+        # dead message-id fails on every pass; once it is known dead
+        # (attempted before, never fetched once) each further failure is
+        # old news, and when it is the only candidate at all, gating on
+        # the others' success alone still mailed the maintainer forever.
+        if result.get('revision_polled') or not result.get('revision_fresh_errors'):
+            logger.debug(
+                'Could not poll %s non-tracked revision(s)', result['revision_errors']
+            )
+        else:
+            logger.warning(
+                'Could not poll %s non-tracked revision(s)', result['revision_errors']
+            )
+    if result.get('branch_busy_skipped'):
         # Normal while the maintainer works on a branch — debug, not
         # cron mail.  The branch catches up on a later sweep.
         logger.debug(
-            '%s branch(es) checked out, left alone', result['checked_out_skipped']
+            '%s branch(es) mid-operation, left alone',
+            result['branch_busy_skipped'],
         )
     for submitter, error in result['error_details']:
         logger.warning('Update error (%s): %s', submitter, error)
diff --git a/src/b4/review/tracking.py b/src/b4/review/tracking.py
index 68701238..1b26d722 100644
--- a/src/b4/review/tracking.py
+++ b/src/b4/review/tracking.py
@@ -1360,6 +1360,23 @@ def set_revision_series_blob(
     return cursor.rowcount > 0
 
 
+def get_revision_thread_blob(
+    conn: sqlite3.Connection, change_id: str, revision: int
+) -> Optional[str]:
+    """The git blob SHA of a revision's cached thread mbox, if one is recorded.
+
+    The read half of :func:`set_revision_thread_blob`.  A caller that only
+    wants the snapshot has no business selecting a column out of the
+    catalog by hand, and one that wants the whole row has
+    :func:`get_revisions`.
+    """
+    row = conn.execute(
+        'SELECT thread_blob FROM revisions WHERE change_id = ? AND revision = ?',
+        (change_id, revision),
+    ).fetchone()
+    return str(row[0]) if row is not None and row[0] else None
+
+
 def add_series_patches(
     conn: sqlite3.Connection, change_id: str, revision: int, lser: 'b4.LoreSeries'
 ) -> None:
@@ -2937,6 +2954,14 @@ def update_message_count_from_msgs(
     *seen_bump* — the caller's count of new-to-the-thread messages that
     are already read (e.g. the maintainer's own replies).
 
+    The thread snapshot always lands on the revision's catalog row, so
+    "which messages were already counted" keeps advancing sweep over
+    sweep even when the tracking commit cannot be refreshed alongside it
+    -- reusing a stale snapshot re-counted the same already-read messages
+    into *seen_bump* every sweep, silently clearing unread badges.  The
+    tracking commit is offered the same snapshot and declines on its own
+    if the branch's worktree is mid-operation; no caller has to ask.
+
     Returns True if the database was changed, False otherwise.
     """
     now = datetime.datetime.now(datetime.timezone.utc).isoformat()
@@ -2955,9 +2980,21 @@ def update_message_count_from_msgs(
     if verdict is None:
         # Unchanged total: stamp the check time and leave the counts --
         # and the cached thread, which this writer overwrites rather than
-        # merging -- alone.
+        # merging -- alone.  Unless git gc has taken that thread since: a
+        # settled series' count never moves again, so this path is its only
+        # chance to notice, and the tracked revision is the one revision the
+        # poller skips, so nothing else will.
         _stamp_check(conn, change_id, revision, now)
         conn.commit()
+        if msgs:
+            _ensure_thread_blob(
+                conn,
+                topdir,
+                change_id,
+                revision,
+                get_revision_thread_blob(conn, change_id, revision),
+                msgs,
+            )
         return False
 
     new_count, new_seen = verdict
@@ -2969,7 +3006,10 @@ def update_message_count_from_msgs(
     _touch_last_mail(conn, change_id, revision, last_mail)
     conn.commit()
     if topdir and msgs:
-        _store_thread_blob(topdir, change_id, msgs)
+        # One serialization and one hash-object for both writers: the SHA
+        # the catalog just recorded is the one the tracking commit needs.
+        blob_sha = store_revision_thread_blob(conn, topdir, change_id, revision, msgs)
+        _store_thread_blob(topdir, change_id, msgs, blob_sha=blob_sha)
     return True
 
 
@@ -3057,12 +3097,22 @@ def _store_revision_blob(
     return blob_sha
 
 
-def _store_thread_blob(topdir: str, change_id: str, msgs: List[Any]) -> Optional[str]:
+def _store_thread_blob(
+    topdir: str, change_id: str, msgs: List[Any], blob_sha: Optional[str] = None
+) -> Optional[str]:
     """Serialize msgs to mboxrd and write as a git blob; update tracking commit.
 
     Also writes thread-context-blob (the plain-text rendered context for the
     AI agent) in the same save_tracking_ref call to avoid a second write.
 
+    *blob_sha* is the SHA these same messages were already written under.
+    Blobs are content-addressed, so re-serializing the mbox and hashing it
+    again only produces the same SHA at the cost of a second
+    ``hash-object`` -- and the caller that caches the thread on the
+    revision's catalog row immediately before calling here has it in hand.
+    Omitted, or None because that write failed, the mbox is written here as
+    before.
+
     Returns the mbox blob SHA, or None on failure.  Non-fatal: a failure here
     just means the next 'f' press will fall back to a live lore fetch.
     """
@@ -3070,7 +3120,8 @@ def _store_thread_blob(topdir: str, change_id: str, msgs: List[Any]) -> Optional
     # that would occur if `import b4.review` appeared after a `b4.xxx` call.
     import b4.review as _b4_review
 
-    blob_sha = _write_mbox_blob(topdir, msgs)
+    if blob_sha is None:
+        blob_sha = _write_mbox_blob(topdir, msgs)
     if blob_sha is None:
         logger.debug('Could not write thread blob for %s', change_id)
         return None
@@ -3357,6 +3408,55 @@ def render_prior_review_context(
     return '\n'.join(lines)
 
 
+def resolve_thread_blob(topdir: Optional[str], series: Dict[str, Any]) -> str:
+    """The freshest recorded thread snapshot for a revision, or '' if none.
+
+    The catalog first, the tracking commit second -- the same precedence
+    :func:`b4.review._prev_thread_msgids` uses, and for the same reason.  A
+    sweep whose branch is checked out must not move the ref, which is the
+    normal state for a series under review, so it advances the catalog copy
+    and leaves the tracking commit's behind.  A reader on the tracking
+    commit alone then serves a thread from before the checkout for as long
+    as the review lasts, and the follow-ups the sweep counted -- the ones
+    that raised the unread badge -- are exactly what it cannot see.
+
+    *series* is a tracking-commit series block, so its keys are the
+    hyphenated ones :func:`b4.review.save_tracking_ref` writes.  The
+    change_id and revision are read out of it rather than passed beside
+    it: a caller that supplies them separately can disagree with the dict
+    it also supplies, and an empty change_id then fails silently -- it
+    skips the catalog and serves the very copy this function exists to
+    stop preferring.
+
+    A recorded SHA that git has since pruned is not a snapshot either.
+    Thread blobs are written with ``hash-object -w`` and referenced only
+    from the database, so any gc may take one while the row keeps its SHA
+    -- and the tracking commit names a different blob, which may still be
+    there.
+
+    Falls back rather than failing: the tracking commit is where this lived
+    before the catalog had a copy, and a database that cannot be opened is
+    not a reason to show nothing.
+    """
+    change_id = str(series.get('change-id') or '')
+    if topdir and change_id:
+        try:
+            identifier = get_repo_identifier(topdir)
+            if identifier:
+                conn = get_db(identifier)
+                try:
+                    sha = get_revision_thread_blob(
+                        conn, change_id, int(series.get('revision') or 1)
+                    )
+                finally:
+                    conn.close()
+                if sha and _thread_blob_exists(topdir, sha):
+                    return sha
+        except Exception as ex:
+            logger.debug('Could not read the catalog thread blob: %s', ex)
+    return str(series.get('thread-blob') or '')
+
+
 def ensure_thread_context_blob(
     topdir: str, change_id: str, series: Dict[str, Any], patches: List[Dict[str, Any]]
 ) -> Optional[str]:
@@ -3375,7 +3475,7 @@ def ensure_thread_context_blob(
     if series.get('thread-context-blob'):
         return str(series['thread-context-blob'])
 
-    blob_sha = series.get('thread-blob')
+    blob_sha = resolve_thread_blob(topdir, series)
     if not blob_sha:
         return None
 
@@ -3661,6 +3761,16 @@ def _poll_due(rev: Dict[str, Any], now: str) -> bool:
     return True
 
 
+# Per-sweep cap on the non-tracked revisions polled for one series.  Each
+# costs a lore round-trip -- one per member patch for a rethreaded revision
+# -- and they are paid one after another before the update returns, so a
+# prolific series would otherwise answer a single 'u' with a round-trip per
+# version it has ever had.  Revisions past the cap come round on a later
+# sweep, oldest check first, so nothing is dropped and a settled series'
+# versions stop coming due all in the same pass.
+REVISION_POLL_LIMIT = 3
+
+
 def update_revision_message_counts(
     identifier: str,
     series_list: List[Dict[str, Any]],
@@ -3669,6 +3779,7 @@ def update_revision_message_counts(
     cancel_cb: Optional[Callable[[], bool]] = None,
     only_revisions: Optional[Set[int]] = None,
     status_cb: Optional[Callable[[str], None]] = None,
+    force: bool = False,
 ) -> Dict[str, int]:
     """Fetch and store thread message counts for non-tracked revisions.
 
@@ -3699,13 +3810,16 @@ def update_revision_message_counts(
     *cancel_cb* is polled between revisions so a cancelled sweep stops
     here rather than grinding through the rest of the catalog.
     *only_revisions* narrows the poll to named versions, for a caller
-    that knows which ones it wants counted; naming them also bypasses the
-    minimum-age skip, since the caller is asking now.  *status_cb*, when
-    given, is handed each polled series' subject, so a caller driving a
-    progress display has something to show during what is otherwise
-    minutes of silent lore traffic.  A subject and nothing else: how far
-    along a sweep is belongs to the sweep, which knows how many series it
-    handed over and has already drawn a bar for them.
+    that knows which ones it wants counted.  *force* bypasses the
+    minimum-age skip -- the explicit interactive refresh, where skipping a
+    recently checked version silently would read as the update not
+    working.  Naming revisions implies it, since a caller that asked for
+    those versions is asking now; the per-sweep cap still applies to both.
+    *status_cb*, when given, is handed each polled series' subject, so a
+    caller driving a progress display has something to show during what
+    is otherwise minutes of silent lore traffic.  A subject and nothing
+    else: how far along a sweep is belongs to the sweep, which knows how
+    many series it handed over and has already drawn a bar for them.
 
     Returns ``{'updated': n, 'new_mail': n, 'errors': n,
     'fresh_errors': n, 'polled': n, 'cancelled': 0-or-1}``.  *cancelled*
@@ -3728,10 +3842,22 @@ def update_revision_message_counts(
     fresh_errors = 0
     polled_total = 0
     cancelled = False
+    # Asking for named versions is asking now, so it carries the same
+    # weight as the flag rather than being a second thing to test for.
+    force = force or only_revisions is not None
     # The same set update_all_tracking() drops, no wider: a late reply lands
     # on an old version of an applied series just as readily as on its
     # tracked one, which is polled past 'accepted'/'thanked' for that reason.
-    skip_statuses = frozenset(('archived', 'snoozed'))
+    #
+    # Snoozed is a sweep policy -- "do not spend the network on this on my
+    # behalf" -- so an explicit single-series ask lifts it: 'u' on a snoozed
+    # row already updates that series' tracked revision, and polling its
+    # other versions is the half of the same request.  Archived is not a
+    # policy but a fact: those rows are filtered out of the tracking list,
+    # so there is no row to press 'u' on and nothing to lift.
+    skip_statuses = (
+        frozenset(('archived',)) if force else frozenset(('archived', 'snoozed'))
+    )
 
     try:
         conn = get_db(identifier)
@@ -3807,7 +3933,7 @@ def update_revision_message_counts(
                     cancelled = True
                     break
                 now = datetime.datetime.now(datetime.timezone.utc).isoformat()
-                if only_revisions is None and not _poll_due(rev, now):
+                if not force and not _poll_due(rev, now):
                     continue
                 first_fetch = rev.get('message_count') is None
                 try:
diff --git a/src/b4/review_tui/_common.py b/src/b4/review_tui/_common.py
index 2d978441..34cd5d28 100644
--- a/src/b4/review_tui/_common.py
+++ b/src/b4/review_tui/_common.py
@@ -461,7 +461,9 @@ class CheckRunnerMixin:
             try:
                 with _quiet_worker():
                     _cover, tracking = b4.review.load_tracking(topdir, review_branch)
-                blob_sha = tracking.get('series', {}).get('thread-blob', '')
+                blob_sha = b4.review.tracking.resolve_thread_blob(
+                    topdir, tracking.get('series', {})
+                )
                 fd, tracking_file = tempfile.mkstemp(
                     prefix='b4-tracking-', suffix='.json'
                 )
diff --git a/src/b4/review_tui/_modals.py b/src/b4/review_tui/_modals.py
index 4acfd0da..23d5f566 100644
--- a/src/b4/review_tui/_modals.py
+++ b/src/b4/review_tui/_modals.py
@@ -2814,12 +2814,19 @@ class UpdateAllScreen(ModalScreen[Dict[str, Any]]):
         identifier: str,
         linkmask: str,
         topdir: Optional[str] = None,
+        force_revision_poll: bool = False,
     ) -> None:
         super().__init__()
         self._series_list = series_list
         self._identifier = identifier
         self._linkmask = linkmask
         self._topdir = topdir
+        # 'u' on one series: the maintainer is asking about that series
+        # right now, so a recently checked version is polled rather than
+        # silently skipped.  'U' leaves the schedule alone -- forcing it
+        # across a few hundred series turns one keypress into a
+        # REVISION_POLL_LIMIT-sized burst of lore traffic per series.
+        self._force_revision_poll = force_revision_poll
         self._cancelled = False
         self._result: Dict[str, Any] = {
             'series_checked': 0,
@@ -2868,6 +2875,7 @@ class UpdateAllScreen(ModalScreen[Dict[str, Any]]):
                     series_list=self._series_list,
                     progress_cb=_on_progress,
                     cancel_cb=_should_cancel,
+                    force_revision_poll=self._force_revision_poll,
                 )
             except b4.LockHeldError:
                 self._result['busy'] = True
diff --git a/src/b4/review_tui/_review_app.py b/src/b4/review_tui/_review_app.py
index 7fe707bb..47d448ed 100644
--- a/src/b4/review_tui/_review_app.py
+++ b/src/b4/review_tui/_review_app.py
@@ -2084,7 +2084,7 @@ class ReviewApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[None]):
         self._show_external_comments = True
 
         # ── Try local blob first, fall back to lore in background ────────────
-        blob_sha = self._series.get('thread-blob', '')
+        blob_sha = b4.review.tracking.resolve_thread_blob(self._topdir, self._series)
         self.notify('Loading follow-ups\u2026')
         run_lore_worker(
             self,
diff --git a/src/b4/review_tui/_tracking_app.py b/src/b4/review_tui/_tracking_app.py
index 42f0f46c..31601ded 100644
--- a/src/b4/review_tui/_tracking_app.py
+++ b/src/b4/review_tui/_tracking_app.py
@@ -2364,7 +2364,11 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         self._focus_change_id = self._selected_series.get('change_id')
         self.push_screen(
             UpdateAllScreen(
-                [self._selected_series], self._identifier, linkmask, topdir
+                [self._selected_series],
+                self._identifier,
+                linkmask,
+                topdir,
+                force_revision_poll=True,
             ),
             callback=self._on_update_complete,
         )
@@ -2413,9 +2417,15 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
             parts.append(f'{updated} updated')
         if errors:
             parts.append(f'{errors} error(s)')
-        skipped = result.get('checked_out_skipped', 0)
+        rev_counts = result.get('revision_counts_updated', 0)
+        if rev_counts:
+            parts.append(f'{rev_counts} other revision(s) with new mail')
+        rev_errors = result.get('revision_errors', 0)
+        if rev_errors:
+            parts.append(f'{rev_errors} revision(s) unreachable')
+        skipped = result.get('branch_busy_skipped', 0)
         if skipped:
-            parts.append(f'{skipped} checked-out branch(es) left alone')
+            parts.append(f'{skipped} busy branch(es) left alone')
 
         severity: Literal['information', 'warning'] = (
             'warning' if errors else 'information'
diff --git a/src/tests/test_review_tracking.py b/src/tests/test_review_tracking.py
index c1f20649..df867938 100644
--- a/src/tests/test_review_tracking.py
+++ b/src/tests/test_review_tracking.py
@@ -4962,8 +4962,8 @@ class TestCmdForget:
         assert self._rows_left('forget-checkedout', 'cid-gone')['series'] == 1
 
 
-class TestUpdateSkipsCheckedOutBranch:
-    """update_series_tracking leaves a checked-out review branch alone."""
+class TestUpdateSkipsABusyWorktree:
+    """update_series_tracking leaves a mid-operation worktree alone."""
 
     def _tracking_data(self, change_id: str) -> Dict[str, Any]:
         return {
@@ -5025,20 +5025,28 @@ class TestUpdateSkipsCheckedOutBranch:
                 series_dict, 'co-test', 'https://example.com/%s', topdir=gitdir
             )
 
-    def test_checked_out_branch_left_alone(self, gitdir: str) -> None:
-        """A checked-out branch is skipped: flag set, tip untouched."""
+    def test_busy_worktree_left_alone(self, gitdir: str) -> None:
+        """A worktree mid-operation is skipped: flag set, tip untouched.
+
+        Checked out is not enough on its own -- the amend reuses the
+        branch's own tree, so a quiescent checkout survives it untouched.
+        What it would strand is the `git am` whose sequencer state names
+        the tip being replaced, and that is what this puts in the way.
+        """
         change_id = 'co-checkedout'
         branch = _create_review_branch(
             gitdir, change_id, self._tracking_data(change_id)
         )
         ecode, _ = b4.git_run_command(gitdir, ['checkout', branch])
         assert ecode == 0
+        # What `git am` leaves behind for as long as it is in flight.
+        os.makedirs(os.path.join(gitdir, '.git', 'rebase-apply'), exist_ok=True)
         ecode, tip_before = b4.git_run_command(gitdir, ['rev-parse', branch])
         assert ecode == 0
 
         result = self._run_update(gitdir, change_id)
 
-        assert result.get('checked_out') is True
+        assert result.get('branch_busy') is True
         assert result.get('error') is None
         ecode, tip_after = b4.git_run_command(gitdir, ['rev-parse', branch])
         assert ecode == 0
@@ -5054,7 +5062,7 @@ class TestUpdateSkipsCheckedOutBranch:
 
         result = self._run_update(gitdir, change_id)
 
-        assert result.get('checked_out') is None
+        assert result.get('branch_busy') is None
         assert result.get('error') == 'Could not find series v1 in retrieved messages'
 
 

-- 
2.53.0


^ permalink raw reply related	[flat|nested] 26+ messages in thread

* [PATCH RFC v2 13/25] review: test the per-revision poll sweep
  2026-08-12 21:46 [PATCH RFC v2 00/25] review: track and browse every version of a tracked series Christian Brauner
                   ` (11 preceding siblings ...)
  2026-08-12 21:46 ` [PATCH RFC v2 12/25] review-tui: poll every revision on u/U updates Christian Brauner
@ 2026-08-12 21:46 ` Christian Brauner
  2026-08-12 21:46 ` [PATCH RFC v2 14/25] review-tui: resolve the tracked revision in revision lists Christian Brauner
                   ` (11 subsequent siblings)
  24 siblings, 0 replies; 26+ messages in thread
From: Christian Brauner @ 2026-08-12 21:46 UTC (permalink / raw)
  To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Christian Brauner (Amutable)

Cover the sweep wiring around update_revision_message_counts(): a busy
branch keeping its poll while the ref is left alone, the thread snapshot
moving onto the catalog row so seen_bump stops re-counting read mail,
offline not counting as a failed poll, a poll that raises being counted
rather than dropped at debug level, and the cron reporting of
per-revision results and unreachable revisions.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 src/tests/test_review.py          | 688 ++++++++++++++++++++++++++++++++++++++
 src/tests/test_review_tracking.py | 281 ++++++++++++++++
 src/tests/test_tui_tracking.py    |  29 ++
 3 files changed, 998 insertions(+)

diff --git a/src/tests/test_review.py b/src/tests/test_review.py
index 4f566898..a7c9cb15 100644
--- a/src/tests/test_review.py
+++ b/src/tests/test_review.py
@@ -1,9 +1,11 @@
 import argparse
 import email.message
 import importlib.util
+import io
 import json
 import logging
 import os
+import sqlite3
 from typing import Any, Dict, Iterator, List, Optional, Tuple, Union
 from unittest import mock
 
@@ -11,6 +13,7 @@ import pytest
 
 import b4
 import b4.review.tracking
+import liblore
 from b4 import review, review_tui
 from b4.review import _review
 from b4.review._review import REVIEW_MAGIC_MARKER, check_series_attestation
@@ -4855,3 +4858,688 @@ class TestCreateReviewBranchCleanup:
         assert ecode == 0
         assert head.strip() == tip
         assert not b4.git_branch_exists(gitdir, branch)
+
+
+def test_update_all_tracking_polls_revisions_capped(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    """The sweep must actually drive the per-revision poller, with a cap.
+
+    Uncapped this is one lore round-trip per known older revision of every
+    tracked series on every 'U' press.
+    """
+    series = [
+        {'change_id': 'a', 'subject': 's-a', 'status': 'new', 'sender_name': 'A'},
+        {'change_id': 'b', 'subject': 's-b', 'status': 'reviewing', 'sender_name': 'B'},
+    ]
+    monkeypatch.setattr(
+        b4.review.tracking, 'get_all_tracked_series', lambda identifier: series
+    )
+    monkeypatch.setattr(
+        review,
+        'update_series_tracking',
+        lambda one, identifier, linkmask, topdir=None: {
+            'new_revisions': 0,
+            'new_trailers': 0,
+            'error': None,
+        },
+    )
+    calls: List[Dict[str, Any]] = []
+
+    def fake_counts(
+        identifier: str,
+        series_list: List[Dict[str, Any]],
+        topdir: Optional[str] = None,
+        max_revisions_per_series: Optional[int] = None,
+        cancel_cb: Optional[Any] = None,
+        status_cb: Optional[Any] = None,
+        force: bool = False,
+    ) -> Dict[str, int]:
+        calls.append(
+            {
+                'change_ids': [s['change_id'] for s in series_list],
+                'cap': max_revisions_per_series,
+                'cancel_cb': cancel_cb,
+                'force': force,
+            }
+        )
+        return {'updated': 5, 'new_mail': 2, 'errors': 0}
+
+    monkeypatch.setattr(
+        b4.review.tracking, 'update_revision_message_counts', fake_counts
+    )
+    result = review.update_all_tracking(
+        'poller',
+        'https://lore.example/r/%s',
+        cancel_cb=lambda: False,
+    )
+    # One call for the whole list: the poller loops internally and applies
+    # the cap per series, so handing it a series at a time only reopened the
+    # database once per entry.
+    assert [c['change_ids'] for c in calls] == [['a', 'b']]
+    assert {c['cap'] for c in calls} == {b4.review.tracking.REVISION_POLL_LIMIT}
+    assert all(c['cancel_cb'] is not None for c in calls)
+    # Nothing asked for a forced poll, so recently checked versions keep
+    # their minimum-age schedule.
+    assert all(c['force'] is False for c in calls)
+    # The summary reports new mail, not every row the poller touched.
+    assert result['revision_counts_updated'] == 2
+    assert result['revision_errors'] == 0
+
+
+def test_update_all_tracking_reports_revision_poll_errors(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    """A poller that never works must not look like a quiet mailing list.
+
+    Counted apart from result['errors'], which drives the per-series error
+    report: a revision that will not fetch is not a series that failed.
+    """
+    series = [{'change_id': 'a', 'subject': 's-a', 'status': 'new', 'sender_name': 'A'}]
+    monkeypatch.setattr(
+        b4.review.tracking, 'get_all_tracked_series', lambda identifier: series
+    )
+    monkeypatch.setattr(
+        review,
+        'update_series_tracking',
+        lambda one, identifier, linkmask, topdir=None: {
+            'new_revisions': 0,
+            'new_trailers': 0,
+            'error': None,
+        },
+    )
+    monkeypatch.setattr(
+        b4.review.tracking,
+        'update_revision_message_counts',
+        lambda *a, **kw: {'updated': 0, 'new_mail': 0, 'errors': 3, 'polled': 0},
+    )
+    result = review.update_all_tracking('poller-err', 'https://lore.example/r/%s')
+    assert result['revision_errors'] == 3
+    assert result['revision_polled'] == 0
+    assert result['errors'] == 0
+
+
+def test_update_all_tracking_feeds_poll_progress(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    """The poll phase reports through the same callback as the series loop.
+
+    It runs after the bar has reached N/N and can be minutes of lore
+    traffic; without its own progress the update modal reads as hung.
+    """
+    series = [{'change_id': 'a', 'subject': 's-a', 'status': 'new', 'sender_name': 'A'}]
+    monkeypatch.setattr(
+        b4.review.tracking, 'get_all_tracked_series', lambda identifier: series
+    )
+    monkeypatch.setattr(
+        review,
+        'update_series_tracking',
+        lambda one, identifier, linkmask, topdir=None: {
+            'new_revisions': 0,
+            'new_trailers': 0,
+            'error': None,
+        },
+    )
+
+    def fake_counts(
+        identifier: str,
+        series_list: List[Dict[str, Any]],
+        topdir: Optional[str] = None,
+        max_revisions_per_series: Optional[int] = None,
+        cancel_cb: Optional[Any] = None,
+        status_cb: Optional[Any] = None,
+        force: bool = False,
+    ) -> Dict[str, int]:
+        assert status_cb is not None
+        status_cb(series_list[0]['subject'])
+        return {'updated': 0, 'new_mail': 0, 'errors': 0, 'polled': 1}
+
+    monkeypatch.setattr(
+        b4.review.tracking, 'update_revision_message_counts', fake_counts
+    )
+    seen: List[Any] = []
+    review.update_all_tracking(
+        'poll-progress',
+        'https://lore.example/r/%s',
+        progress_cb=lambda c, t, s: seen.append((c, t, s)),
+    )
+    # Held at N/N: the series loop already drove the bar there, and the
+    # poller hands over a subject and nothing else, so there is no second
+    # count to walk it backwards.  The label carries the poll's progress.
+    polled = [(c, t) for c, t, s in seen if s.startswith('Polling earlier')]
+    assert polled == [(1, 1)]
+
+
+@pytest.mark.parametrize(
+    ('polled', 'fresh', 'level'),
+    [(0, 3, 'WARNING'), (2, 3, 'DEBUG'), (0, 0, 'DEBUG')],
+)
+def test_cron_reports_unreachable_revisions_only_when_nothing_polled(
+    monkeypatch: pytest.MonkeyPatch,
+    caplog: pytest.LogCaptureFixture,
+    polled: int,
+    fresh: int,
+    level: str,
+) -> None:
+    """One dead message-id must not mail the maintainer on every sweep.
+
+    Nothing ever retires a revision whose message-id is permanently gone:
+    it keeps its place in the rotation and fails again on every pass.  A
+    sweep that polled something has a working poller and says so quietly.
+    And an empty sweep is only worth cron mail when something failed for
+    the *first* time: a known-dead revision (attempted before, never
+    fetched once) failing again is old news -- gating on the other
+    revisions' success alone still mailed forever whenever the dead one
+    was the only candidate at all.
+    """
+    monkeypatch.setattr(
+        _review,
+        'update_all_tracking',
+        lambda identifier, linkmask, topdir=None, **kw: {
+            'series_checked': 1,
+            'series_updated': 0,
+            'errors': 0,
+            'gone': 0,
+            'followup_updated': 0,
+            'revision_counts_updated': 0,
+            'revision_errors': 3,
+            'revision_fresh_errors': fresh,
+            'revision_polled': polled,
+            'branch_busy_skipped': 0,
+            'error_details': [],
+            'cancelled': False,
+        },
+    )
+    with caplog.at_level(logging.DEBUG, logger=b4.logger.name):
+        _review._cron_update('cron-poller', None)
+    records = [r for r in caplog.records if 'Could not poll' in r.getMessage()]
+    assert [r.levelname for r in records] == [level]
+
+
+def test_update_all_tracking_cancelled_poller_stops_sweep(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    """Cancellation now reaches the handler -- the fetch helpers used to
+    swallow OperationCancelledError, making that branch unreachable.
+
+    The poll is one batched call after the series loop, so a cancel raised
+    there marks the sweep cancelled without unwinding the per-series work
+    that already finished.  A cancel the *maintainer* asks for still stops
+    the series loop itself: it checks cancel_cb at the top of every pass.
+    """
+    series = [
+        {'change_id': 'a', 'subject': 's-a', 'status': 'new', 'sender_name': 'A'},
+        {'change_id': 'b', 'subject': 's-b', 'status': 'new', 'sender_name': 'B'},
+    ]
+    monkeypatch.setattr(
+        b4.review.tracking, 'get_all_tracked_series', lambda identifier: series
+    )
+    monkeypatch.setattr(
+        review,
+        'update_series_tracking',
+        lambda one, identifier, linkmask, topdir=None: {
+            'new_revisions': 0,
+            'new_trailers': 0,
+            'error': None,
+        },
+    )
+
+    def cancelled(*a: Any, **kw: Any) -> Dict[str, int]:
+        raise liblore.OperationCancelledError('Request cancelled')
+
+    monkeypatch.setattr(b4.review.tracking, 'update_revision_message_counts', cancelled)
+    result = review.update_all_tracking('poller-cancel', 'https://lore.example/r/%s')
+    assert result['cancelled'] is True
+    assert result['series_checked'] == 2
+
+
+def _stub_sweep(monkeypatch: pytest.MonkeyPatch, series: List[Dict[str, Any]]) -> None:
+    """Point update_all_tracking at *series* with a no-op per-series update."""
+    monkeypatch.setattr(
+        b4.review.tracking, 'get_all_tracked_series', lambda identifier: series
+    )
+    monkeypatch.setattr(
+        review,
+        'update_series_tracking',
+        lambda one, identifier, linkmask, topdir=None: {
+            'new_revisions': 0,
+            'new_trailers': 0,
+            'error': None,
+        },
+    )
+
+
+def test_a_busy_branch_still_polls_its_revisions(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    """Only the branch is left alone, not the per-revision poll.
+
+    The poll writes DB rows and a loose blob and moves no ref, while a
+    series under review is normally the checked-out branch -- so skipping
+    it there is skipping it almost always, and late replies to old
+    versions stop being noticed at all.
+    """
+    series = [
+        {'change_id': 'a', 'subject': 's-a', 'status': 'new', 'sender_name': 'A'},
+        {'change_id': 'b', 'subject': 's-b', 'status': 'new', 'sender_name': 'B'},
+    ]
+    monkeypatch.setattr(
+        b4.review.tracking, 'get_all_tracked_series', lambda identifier: series
+    )
+    monkeypatch.setattr(
+        review,
+        'update_series_tracking',
+        lambda one, identifier, linkmask, topdir=None: {
+            'new_revisions': 0,
+            'new_trailers': 0,
+            'error': None,
+            'branch_busy': one['change_id'] == 'a',
+        },
+    )
+    polled: List[str] = []
+
+    def _record(
+        identifier: str, series_list: List[Dict[str, Any]], **kw: Any
+    ) -> Dict[str, int]:
+        polled.extend(s['change_id'] for s in series_list)
+        return {'updated': 0, 'new_mail': 0, 'errors': 0}
+
+    monkeypatch.setattr(b4.review.tracking, 'update_revision_message_counts', _record)
+    result = review.update_all_tracking('poll-co', 'https://lore.example/r/%s')
+    # 'a' is the checked-out one, and it is still handed to the poller.
+    assert polled == ['a', 'b']
+    assert result['branch_busy_skipped'] == 1
+
+
+def test_update_all_tracking_forwards_the_forced_poll(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    """A user-initiated sweep polls recently checked versions too.
+
+    The poller's minimum-age skip exists for unattended sweeps; on an
+    explicit u/U it silently withheld exactly the per-version counts the
+    refresh was pressed for.
+    """
+    series = [{'change_id': 'a', 'subject': 's-a', 'status': 'new', 'sender_name': 'A'}]
+    monkeypatch.setattr(
+        b4.review.tracking, 'get_all_tracked_series', lambda identifier: series
+    )
+    monkeypatch.setattr(
+        review,
+        'update_series_tracking',
+        lambda one, identifier, linkmask, topdir=None: {
+            'new_revisions': 0,
+            'new_trailers': 0,
+            'error': None,
+        },
+    )
+    forced: List[bool] = []
+
+    def fake_counts(
+        identifier: str,
+        series_list: List[Dict[str, Any]],
+        force: bool = False,
+        **kw: Any,
+    ) -> Dict[str, int]:
+        forced.append(force)
+        return {'updated': 0, 'new_mail': 0, 'errors': 0}
+
+    monkeypatch.setattr(
+        b4.review.tracking, 'update_revision_message_counts', fake_counts
+    )
+    review.update_all_tracking(
+        'force-poll', 'https://lore.example/r/%s', force_revision_poll=True
+    )
+    assert forced == [True]
+
+
+def test_busy_reported_only_when_an_update_was_forgone(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    """The busy-branch report follows the statuses whose branch the sweep writes.
+
+    branch_busy guards every ref writer, but an accepted or thanked
+    series' branch was never going to be written -- counting it made the
+    'N checked-out branch(es) left alone' toast report branches the sweep
+    never touches, on every sweep, for as long as they stay checked out.
+    """
+    monkeypatch.setattr(b4, 'git_worktree_busy', lambda topdir, branch: True)
+    monkeypatch.setattr(
+        b4.review.tracking, 'store_revision_thread_blob', lambda *a, **kw: None
+    )
+    monkeypatch.setattr(b4.review.tracking, '_store_thread_blob', lambda *a, **kw: None)
+
+    def _run(status: str) -> Dict[str, Any]:
+        identifier = f'cog-{status}'
+        change_id = f'cog-{status}-cid'
+        conn = b4.review.tracking.init_db(identifier)
+        b4.review.tracking.add_series_to_db(
+            conn,
+            change_id,
+            1,
+            'Subject',
+            'Author',
+            'a@example.com',
+            '2024-01-15T10:00:00+00:00',
+            'cover@example.com',
+            2,
+        )
+        b4.review.tracking.update_series_status(conn, change_id, status)
+        conn.close()
+
+        msg = email.message.EmailMessage()
+        msg['Message-Id'] = f'<{change_id}-r1@example.com>'
+        msg['Date'] = 'Mon, 27 Jul 2026 10:00:00 +0000'
+        mock_lmbx = mock.Mock()
+        mock_lmbx.series = {}
+        mock_lmbx.covers = {}
+        mock_lmbx.get_series.return_value = None
+        series_dict: Dict[str, Any] = {
+            'change_id': change_id,
+            'revision': 1,
+            'status': status,
+            'message_id': 'cover@example.com',
+        }
+        with (
+            mock.patch(
+                'b4.review._review.retrieve_series_messages', return_value=[msg]
+            ),
+            mock.patch('b4.LoreMailbox', return_value=mock_lmbx),
+        ):
+            return review.update_series_tracking(
+                series_dict, identifier, 'https://example.com/%s', topdir='/nonexistent'
+            )
+
+    assert _run('reviewing').get('branch_busy') is True
+    assert not _run('accepted').get('branch_busy')
+
+
+def _seed_catalog_blob(identifier: str, blob_sha: str) -> None:
+    conn = b4.review.tracking.init_db(identifier)
+    b4.review.tracking.add_revision(conn, 'cid', 1, 'v1@x')
+    conn.execute(
+        "UPDATE revisions SET thread_blob = ? WHERE change_id = 'cid'", (blob_sha,)
+    )
+    conn.commit()
+    conn.close()
+
+
+def test_prev_thread_msgids_reads_the_catalog_snapshot(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    """The catalog row's blob outranks the tracking commit's.
+
+    The catalog copy advances on every counted fetch, including sweeps
+    that leave a checked-out branch's ref alone; the tracking commit's
+    blob is frozen then, and deriving seen bumps from it re-counted the
+    same read messages as new on every sweep.
+    """
+    _seed_catalog_blob('prev-snap', 'c0ffee')
+    msgs = []
+    for i in range(2):
+        msg = email.message.EmailMessage()
+        msg['Message-Id'] = f'<prev-{i}@example.com>'
+        msg['Date'] = 'Mon, 27 Jul 2026 10:00:00 +0000'
+        msg.set_content('body')
+        msgs.append(msg)
+    buf = io.BytesIO()
+    b4.save_mboxrd_mbox(msgs, buf)
+    asked: List[str] = []
+
+    def _mbox(topdir: str, sha: str) -> bytes:
+        asked.append(sha)
+        return buf.getvalue()
+
+    monkeypatch.setattr(b4.review.tracking, 'get_thread_mbox', _mbox)
+
+    def _no_branch(*a: Any, **kw: Any) -> bool:
+        raise AssertionError('tracking commit consulted despite a catalog snapshot')
+
+    monkeypatch.setattr(b4, 'git_branch_exists', _no_branch)
+    got = _review._prev_thread_msgids('/nonexistent', 'prev-snap', 'cid', 1)
+    assert asked == ['c0ffee']
+    assert got == {'prev-0@example.com', 'prev-1@example.com'}
+
+
+def test_prev_thread_msgids_pruned_catalog_blob_is_no_snapshot(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    """A recorded blob that no longer reads back yields None, not a fallback.
+
+    The tracking commit's copy is older than the pruned one, and bumps
+    against it were already applied; "cannot tell which messages are
+    new" is the honest answer.
+    """
+    _seed_catalog_blob('prev-gcd', 'deadbeef')
+    monkeypatch.setattr(b4.review.tracking, 'get_thread_mbox', lambda topdir, sha: None)
+
+    def _no_branch(*a: Any, **kw: Any) -> bool:
+        raise AssertionError('tracking commit consulted despite a catalog snapshot')
+
+    monkeypatch.setattr(b4, 'git_branch_exists', _no_branch)
+    assert _review._prev_thread_msgids('/nonexistent', 'prev-gcd', 'cid', 1) is None
+
+
+def test_a_raising_poller_is_counted_not_swallowed(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    """At debug level the whole feature can break while sweeps look clean."""
+    _stub_sweep(
+        monkeypatch,
+        [{'change_id': 'a', 'subject': 's-a', 'status': 'new', 'sender_name': 'A'}],
+    )
+
+    def _boom(*a: Any, **kw: Any) -> Dict[str, int]:
+        raise sqlite3.OperationalError('database is locked')
+
+    monkeypatch.setattr(b4.review.tracking, 'update_revision_message_counts', _boom)
+    result = review.update_all_tracking('poll-raise', 'https://lore.example/r/%s')
+    assert result['revision_errors'] == 1
+    # Still not a per-series failure: the series itself updated fine.
+    assert result['errors'] == 0
+
+
+def test_a_cancelled_sweep_does_not_start_the_poller(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    """The poll is minutes of lore traffic; a cancel must land before it."""
+    _stub_sweep(
+        monkeypatch,
+        [{'change_id': 'a', 'subject': 's-a', 'status': 'new', 'sender_name': 'A'}],
+    )
+    called: List[int] = []
+
+    def _record(*a: Any, **kw: Any) -> Dict[str, int]:
+        called.append(1)
+        return {'updated': 0, 'new_mail': 0, 'errors': 0}
+
+    monkeypatch.setattr(b4.review.tracking, 'update_revision_message_counts', _record)
+    review.update_all_tracking(
+        'poll-cancelled', 'https://lore.example/r/%s', cancel_cb=lambda: True
+    )
+    assert called == []
+
+
+class TestCancelledPollKeepsItsTally:
+    """A cancel mid-poll must not discard the work already committed.
+
+    The per-revision poller commits each revision as it goes, so a cancel
+    raised on revision N leaves revisions 1..N-1 counted in the database.
+    Reporting that sweep as `{'revision_polled': 0}` makes those badges
+    appear with nothing having announced them -- and makes a permanently
+    broken poller read exactly like a cancelled one.
+    """
+
+    @staticmethod
+    def _msgs(count: int) -> List[Any]:
+        """A thread of *count* messages, each carrying a Date header."""
+        out: List[Any] = []
+        for i in range(count):
+            msg = email.message.EmailMessage()
+            msg['Message-ID'] = f'<poll-{i}@example.com>'
+            msg['Subject'] = f'Re: [PATCH] thing ({i})'
+            msg['Date'] = 'Tue, 07 Jul 2026 12:00:00 +0000'
+            msg.set_content(f'body {i}')
+            out.append(msg)
+        return out
+
+    def test_partial_poll_tallies_survive_a_cancel(
+        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Any
+    ) -> None:
+        identifier = 'poll-cancel-partial'
+        conn = b4.review.tracking.init_db(identifier)
+        # Two older versions, both already counted once: a second fetch that
+        # grows is then real new mail rather than a first count, which the
+        # sweep deliberately does not report as new mail.
+        for rev in (1, 2):
+            b4.review.tracking.add_revision(conn, 'a', rev, f'a-v{rev}@example.com')
+        conn.execute(
+            'UPDATE revisions SET message_count = 2, seen_message_count = 2'
+            " WHERE change_id = 'a'"
+        )
+        conn.commit()
+        conn.close()
+
+        series = [
+            {
+                'change_id': 'a',
+                'revision': 3,
+                'subject': 's-a',
+                'status': 'new',
+                'sender_name': 'A',
+            }
+        ]
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(
+            b4.review.tracking, 'get_all_tracked_series', lambda identifier: series
+        )
+        monkeypatch.setattr(
+            review,
+            'update_series_tracking',
+            lambda one, identifier, linkmask, topdir=None: {
+                'new_revisions': 0,
+                'new_trailers': 0,
+                'error': None,
+            },
+        )
+
+        fetched: List[int] = []
+
+        def fetch(
+            identifier_: str,
+            conn_: Any,
+            change_id: str,
+            rev: Dict[str, Any],
+        ) -> List[Any]:
+            fetched.append(int(rev['revision']))
+            if len(fetched) == 1:
+                # Grew 2 -> 3: one revision's worth of genuine new mail,
+                # committed before the cancel lands.
+                return TestCancelledPollKeepsItsTally._msgs(3)
+            raise liblore.OperationCancelledError('Request cancelled')
+
+        monkeypatch.setattr(b4.review.tracking, '_fetch_revision_thread_msgs', fetch)
+
+        result = review.update_all_tracking(identifier, 'https://lore.example/r/%s')
+
+        # One revision completed, the next raised.
+        assert len(fetched) == 2
+        assert result['cancelled'] is True
+
+        # The completed revision really is committed -- this is the work the
+        # summary below has to account for, not a hypothetical.
+        conn = b4.review.tracking.get_db(identifier)
+        grown = conn.execute(
+            "SELECT COUNT(*) FROM revisions WHERE change_id = 'a' AND message_count = 3"
+        ).fetchone()[0]
+        conn.close()
+        assert grown == 1
+
+        # ...so the sweep must report it rather than returning zeros.
+        assert result['revision_polled'] == 1
+        assert result['revision_counts_updated'] == 1
+
+
+class TestExplicitUpdateReachesASnoozedSeries:
+    """[u] on one row is a question about that row, [U] is a sweep.
+
+    An explicit single-series update already spends the larger network
+    cost on a snoozed series -- update_all_tracking does not filter a
+    caller-supplied series_list, so the tracked revision's thread is
+    fetched and its trailers re-read.  Declining only the cheaper
+    per-revision top-up afterwards is not a network-thrift policy, it is
+    an inconsistency: the maintainer asked about this series and got a
+    partial answer with nothing saying so.  The scheduled sweep keeps
+    skipping snoozed rows, which is what 'snoozed' is for.
+    """
+
+    @staticmethod
+    def _seed(identifier: str) -> List[Dict[str, Any]]:
+        conn = b4.review.tracking.init_db(identifier)
+        for rev in (1, 2):
+            b4.review.tracking.add_revision(conn, 'snz', rev, f'snz-v{rev}@example.com')
+        conn.close()
+        return [
+            {
+                'change_id': 'snz',
+                'revision': 2,
+                'subject': 's-snz',
+                'status': 'snoozed',
+                'sender_name': 'S',
+            }
+        ]
+
+    def _run(
+        self,
+        identifier: str,
+        monkeypatch: pytest.MonkeyPatch,
+        forced: bool,
+    ) -> List[int]:
+        series_list = self._seed(identifier)
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(
+            review,
+            'update_series_tracking',
+            lambda one, ident, linkmask, topdir=None: {
+                'new_revisions': 0,
+                'new_trailers': 0,
+                'error': None,
+            },
+        )
+        fetched: List[int] = []
+
+        def fetch(
+            ident: str, conn: Any, change_id: str, rev: Dict[str, Any]
+        ) -> List[Any]:
+            fetched.append(int(rev['revision']))
+            msg = email.message.EmailMessage()
+            msg['Message-ID'] = '<snz-reply@example.com>'
+            msg['Date'] = 'Tue, 07 Jul 2026 12:00:00 +0000'
+            msg.set_content('late reply')
+            return [msg]
+
+        monkeypatch.setattr(b4.review.tracking, '_fetch_revision_thread_msgs', fetch)
+        review.update_all_tracking(
+            identifier,
+            'https://lore.example/r/%s',
+            series_list=series_list,
+            force_revision_poll=forced,
+        )
+        return fetched
+
+    def test_u_polls_the_older_versions_of_a_snoozed_series(
+        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Any
+    ) -> None:
+        """[u] is the maintainer asking now -- answer about every version."""
+        assert self._run('snooze-forced', monkeypatch, forced=True) == [1]
+
+    def test_the_scheduled_sweep_still_leaves_a_snoozed_series_alone(
+        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Any
+    ) -> None:
+        """...but nothing unattended goes near it.
+
+        Pinned alongside the case above so the fix cannot be "drop
+        skip_statuses", which would put every snoozed series back into
+        the cron sweep's lore budget.
+        """
+        assert self._run('snooze-sweep', monkeypatch, forced=False) == []
diff --git a/src/tests/test_review_tracking.py b/src/tests/test_review_tracking.py
index df867938..e2fcdf85 100644
--- a/src/tests/test_review_tracking.py
+++ b/src/tests/test_review_tracking.py
@@ -5054,6 +5054,27 @@ class TestUpdateSkipsABusyWorktree:
         # DB-side maintenance still ran
         assert result.get('counts_updated') is True
 
+    def test_a_quiescent_checkout_is_written(self, gitdir: str) -> None:
+        """Checked out is not busy, so the branch section is entered.
+
+        Skipping every checkout deferred the tracking commit for as long
+        as a series stayed under review -- which is most of its life --
+        over a write that cannot disturb a worktree standing still.  It
+        reaches the mocked-away series and fails on the sentinel error,
+        which is what shows it got that far.
+        """
+        change_id = 'co-quiescent'
+        branch = _create_review_branch(
+            gitdir, change_id, self._tracking_data(change_id)
+        )
+        ecode, _ = b4.git_run_command(gitdir, ['checkout', branch])
+        assert ecode == 0
+
+        result = self._run_update(gitdir, change_id)
+
+        assert result.get('branch_busy') is None
+        assert result.get('error') == 'Could not find series v1 in retrieved messages'
+
     def test_parked_branch_still_updated(self, gitdir: str) -> None:
         """Control: with the branch not checked out the section is entered
         (and fails on the mocked-away series — the sentinel error)."""
@@ -7942,6 +7963,60 @@ class TestPrunedThreadBlobIsReCached:
         )
         assert stored == [8]
 
+    @staticmethod
+    def _quiet_tracked_update(
+        identifier: str, monkeypatch: pytest.MonkeyPatch, blob_alive: bool
+    ) -> list[int]:
+        """Run the tracked revision's writer over an unchanged thread."""
+        conn = review_tracking.init_db(identifier)
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 2, seen_message_count = 2,'
+            " thread_blob = 'deadbeef' WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+        stored: list[int] = []
+        monkeypatch.setattr(
+            review_tracking, '_thread_blob_exists', lambda topdir, sha: blob_alive
+        )
+        monkeypatch.setattr(
+            review_tracking,
+            'store_revision_thread_blob',
+            lambda conn, topdir, change_id, revision, msgs: stored.append(len(msgs)),
+        )
+        conn = review_tracking.get_db(identifier)
+        review_tracking.update_message_count_from_msgs(
+            conn, 'cid', 1, _thread_msgs(2), topdir='/nonexistent'
+        )
+        conn.close()
+        return stored
+
+    def test_a_gc_d_blob_is_replaced_on_a_quiet_tracked_update(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """The tracked revision is the one the poller never covers.
+
+        _tracked_revisions keeps the poll off it, so its writer's quiet
+        path is the only place that can notice git gc took its thread --
+        and a settled series (accepted, thanked, waiting on a new version)
+        is exactly the one whose count stops moving for weeks.  Left
+        unnoticed, _prev_thread_msgids reads back nothing and the
+        maintainer's own replies badge as unread on the next sweep that
+        does see mail.
+        """
+        assert self._quiet_tracked_update('tracked-blob-gc', monkeypatch, False) == [2]
+
+    def test_a_live_blob_is_left_alone_on_a_quiet_tracked_update(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Same rule as the poller's: existence, not a rewrite.
+
+        Re-serializing an mbox that is already stored under that SHA would
+        also throw away a stitched series_blob that is still good.
+        """
+        assert self._quiet_tracked_update('tracked-blob-live', monkeypatch, True) == []
+
 
 class TestSeenWritersStayConsistent:
     @staticmethod
@@ -8071,3 +8146,209 @@ class TestMigrationDeclinesWhatItCannotCarry:
         conn.close()
         assert 'branch_sha' not in series_cols
         assert 'branch_sha' in chg_cols
+
+
+class TestForceLiftsTheSnoozedSkip:
+    """Snoozed is a sweep policy, not a fact about the series.
+
+    'u' on a snoozed row already updates that series' tracked revision, so
+    silently declining to poll its other versions answers half the request.
+    'U' and the cron sweep never set force, so they keep skipping.
+    """
+
+    def _polled(
+        self, identifier: str, status: str, force: bool, monkeypatch: pytest.MonkeyPatch
+    ) -> int:
+        conn = review_tracking.init_db(identifier)
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='[PATCH v2] s',
+            sender_name='S',
+            sender_email='s@e.com',
+            sent_at='2026-01-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=1,
+        )
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.close()
+        monkeypatch.setattr(b4, 'can_network', True)
+        seen: list[int] = []
+
+        def _fetch(
+            identifier: str,
+            conn: Any,
+            change_id: str,
+            rev: Dict[str, Any],
+        ) -> list[EmailMessage]:
+            seen.append(int(rev['revision']))
+            return _thread_msgs(3)
+
+        monkeypatch.setattr(review_tracking, '_fetch_revision_thread_msgs', _fetch)
+        review_tracking.update_revision_message_counts(
+            identifier,
+            [_poller_series('cid', 2, 'v2@x', status=status)],
+            force=force,
+        )
+        return len(seen)
+
+    def test_a_sweep_still_skips_snoozed(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        assert self._polled('snz-sweep', 'snoozed', False, monkeypatch) == 0
+
+    def test_asking_directly_polls_it(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        assert self._polled('snz-force', 'snoozed', True, monkeypatch) == 1
+
+    def test_archived_is_skipped_even_then(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """No tracking-list row means no way to ask, so nothing to lift."""
+        assert self._polled('arc-force', 'archived', True, monkeypatch) == 0
+
+
+class TestThreadBlobIsHashedOnce:
+    """Both writers of the snapshot want the same blob.
+
+    Blobs are content-addressed, so serializing the mbox a second time and
+    running hash-object on it again produces the SHA the first write
+    already returned -- at the cost of a second subprocess, on every sweep
+    for every series whose count moved.
+    """
+
+    def test_one_write_serves_the_catalog_and_the_tracking_commit(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        conn = review_tracking.init_db('blob-once')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+
+        writes: list[int] = []
+
+        def _fake_write(topdir: str, msgs: Any) -> str:
+            writes.append(len(msgs))
+            return 'a' * 40
+
+        monkeypatch.setattr(review_tracking, '_write_mbox_blob', _fake_write)
+        msg = EmailMessage()
+        msg['Message-Id'] = '<v1@x>'
+        msg['Date'] = 'Mon, 27 Jul 2026 10:00:00 +0000'
+
+        assert review_tracking.update_message_count_from_msgs(
+            conn, 'cid', 1, [msg], topdir='/nonexistent'
+        )
+        conn.close()
+        assert writes == [1]
+
+    def test_a_failed_write_still_lets_the_second_writer_try(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """None is 'no SHA to reuse', not 'do not bother'."""
+        conn = review_tracking.init_db('blob-once-fail')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+
+        writes: list[int] = []
+
+        def _failing_write(topdir: str, msgs: Any) -> None:
+            writes.append(len(msgs))
+            return None
+
+        monkeypatch.setattr(review_tracking, '_write_mbox_blob', _failing_write)
+        msg = EmailMessage()
+        msg['Message-Id'] = '<v1@x>'
+        msg['Date'] = 'Mon, 27 Jul 2026 10:00:00 +0000'
+
+        review_tracking.update_message_count_from_msgs(
+            conn, 'cid', 1, [msg], topdir='/nonexistent'
+        )
+        conn.close()
+        assert writes == [1, 1]
+
+
+class TestThreadBlobResolution:
+    """A frozen tracking commit must not serve a stale thread.
+
+    A sweep whose branch is checked out -- the normal state for a series
+    under review -- advances the catalog copy and leaves the tracking
+    commit's behind.  Every reader of the snapshot has to prefer the
+    catalog, or the follow-ups that raised the unread badge are exactly
+    the ones it cannot see.
+    """
+
+    @staticmethod
+    def _blob(gitdir: str, text: str) -> str:
+        """A real blob, so the existence check has something to find."""
+        ecode, out = b4.git_run_command(
+            gitdir, ['hash-object', '-w', '--stdin'], stdin=text.encode()
+        )
+        assert ecode == 0
+        return out.strip()
+
+    @staticmethod
+    def _enroll(gitdir: str, identifier: str) -> None:
+        review_tracking.save_repo_metadata(
+            b4.git_get_common_dir(gitdir) or gitdir, identifier
+        )
+
+    @staticmethod
+    def _block(**extra: Any) -> Dict[str, Any]:
+        """A tracking-commit series block, spelled the way b4 writes one."""
+        block: Dict[str, Any] = {
+            'change-id': 'cid',
+            'revision': 2,
+            'thread-blob': 'stalesha',
+        }
+        block.update(extra)
+        return block
+
+    def _seed(self, gitdir: str, identifier: str, blob: Optional[str]) -> None:
+        conn = review_tracking.init_db(identifier)
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+        if blob is not None:
+            review_tracking.set_revision_thread_blob(conn, 'cid', 2, blob)
+        conn.close()
+        self._enroll(gitdir, identifier)
+
+    def test_catalog_wins_over_a_frozen_tracking_commit(self, gitdir: str) -> None:
+        fresh = self._blob(gitdir, 'the thread as the sweep last counted it')
+        self._seed(gitdir, 'blob-resolve', fresh)
+        assert review_tracking.resolve_thread_blob(gitdir, self._block()) == fresh
+
+    def test_the_block_is_read_rather_than_described(self, gitdir: str) -> None:
+        """The id comes out of the block, so it cannot disagree with it.
+
+        save_tracking_ref() writes 'change-id'.  A reader handed the id
+        beside the block asked for 'change_id', got '', and fell through to
+        the frozen copy without a word -- the catalog lookup is guarded on
+        a non-empty id, so the miss is silent.  With no second argument to
+        get wrong, a block carrying no id at all is what is left.
+        """
+        fresh = self._blob(gitdir, 'fresh')
+        self._seed(gitdir, 'blob-keys', fresh)
+        block = self._block()
+        del block['change-id']
+        assert review_tracking.resolve_thread_blob(gitdir, block) == 'stalesha'
+
+    def test_falls_back_to_the_tracking_commit(self, gitdir: str) -> None:
+        """Rows written before the catalog had a copy still resolve."""
+        self._seed(gitdir, 'blob-fallback', None)
+        assert review_tracking.resolve_thread_blob(gitdir, self._block()) == 'stalesha'
+
+    def test_a_pruned_catalog_blob_is_not_a_snapshot(self, gitdir: str) -> None:
+        """gc takes loose blobs; the SHA on the row outlives them.
+
+        Thread blobs are written with hash-object -w and referenced only
+        from the database, so a recorded SHA can name an object that is no
+        longer there.  Returning it anyway shadows the tracking commit's
+        copy -- a different blob, which may well still be readable -- and
+        hands the caller a dead SHA where it had a working one before the
+        catalog existed.
+        """
+        self._seed(gitdir, 'blob-pruned', 'd' * 40)
+        assert review_tracking.resolve_thread_blob(gitdir, self._block()) == 'stalesha'
+
+    def test_no_topdir_still_answers(self) -> None:
+        assert review_tracking.resolve_thread_blob(None, {'thread-blob': 'x'}) == 'x'
+        assert review_tracking.resolve_thread_blob(None, {}) == ''
diff --git a/src/tests/test_tui_tracking.py b/src/tests/test_tui_tracking.py
index cc80e7b1..2803bf3d 100644
--- a/src/tests/test_tui_tracking.py
+++ b/src/tests/test_tui_tracking.py
@@ -6001,3 +6001,32 @@ class TestRethreadFlagReachesTheThreadFetch:
             screen._fetch_thread()
         assert seen['is_rethreaded'] is True
         assert seen['revision'] == 2
+
+
+class TestUpdateAllDoesNotForceThePoll:
+    """'u' asks about one series; 'U' must not force the schedule everywhere.
+
+    Both keys push the same UpdateAllScreen, so a single flag on the screen
+    made 'U' bypass the minimum-age skip for every non-snoozed series --
+    REVISION_POLL_LIMIT round-trips apiece where a scheduled sweep would
+    have done almost none.
+    """
+
+    @pytest.mark.asyncio
+    async def test_u_forces_and_capital_u_does_not(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        _seed_db('force-scope', SAMPLE_SERIES)
+        seen = []
+
+        app = TrackingApp('force-scope')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            with patch.object(
+                app, 'push_screen', lambda s, callback=None: seen.append(s)
+            ):
+                app.action_update_one()
+                app.action_update_all()
+        assert len(seen) == 2
+        assert seen[0]._force_revision_poll is True
+        assert seen[1]._force_revision_poll is False

-- 
2.53.0


^ permalink raw reply related	[flat|nested] 26+ messages in thread

* [PATCH RFC v2 14/25] review-tui: resolve the tracked revision in revision lists
  2026-08-12 21:46 [PATCH RFC v2 00/25] review: track and browse every version of a tracked series Christian Brauner
                   ` (12 preceding siblings ...)
  2026-08-12 21:46 ` [PATCH RFC v2 13/25] review: test the per-revision poll sweep Christian Brauner
@ 2026-08-12 21:46 ` Christian Brauner
  2026-08-12 21:46 ` [PATCH RFC v2 15/25] review-tui: fall back when a cached thread blob has no series Christian Brauner
                   ` (10 subsequent siblings)
  24 siblings, 0 replies; 26+ messages in thread
From: Christian Brauner @ 2026-08-12 21:46 UTC (permalink / raw)
  To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Christian Brauner (Amutable)

The revisions catalog is not guaranteed a row for the tracked revision,
since manually linking a newer version records only that one.  Anything
reasoning about the versions of a series from the catalog alone can
therefore omit the version the maintainer is actually on, and a
range-diff then cannot resolve the side it is taken against, on a row
whose binding was enabled on exactly that basis.

Add merge_tracked_revisions(), which appends an entry synthesized from
the series row when the catalog lacks one, and route both readers through
it: the DB-side resolver get_revisions_with_tracked() and the TUI's row
builder.  The versions shown and the versions a range-diff can resolve
are then always the same set.

It covers every live series row, not just the furthest along.
rescan_branches() can leave a change_id with more than one and the
tracking list renders each separately, so resolving only one would enable
the range-diff on a version row whose message-id cannot then be found.

The synthesized entry carries neither blob nor read state.  Both live on
the catalog row it stands in for, so a series row that never got one has
none to report.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 src/b4/review/tracking.py          | 100 +++++++++++++++++++++++++++++++++++++
 src/b4/review_tui/_common.py       |   6 ++-
 src/b4/review_tui/_tracking_app.py |  34 +++++++++++++
 3 files changed, 139 insertions(+), 1 deletion(-)

diff --git a/src/b4/review/tracking.py b/src/b4/review/tracking.py
index 1b26d722..c8d9e7d9 100644
--- a/src/b4/review/tracking.py
+++ b/src/b4/review/tracking.py
@@ -1649,6 +1649,106 @@ def get_revisions(conn: sqlite3.Connection, change_id: str) -> list[dict[str, An
     return [dict(zip(_REVISION_COLS, row)) for row in cursor.fetchall()]
 
 
+def merge_tracked_revisions(
+    change_id: str,
+    revs: List[dict[str, Any]],
+    series_rows: List[dict[str, Any]],
+) -> List[dict[str, Any]]:
+    """Merge catalog *revs* with the revisions the *series_rows* track.
+
+    A catalog row for a tracked revision is not guaranteed -- manually
+    linking a newer version records only that one -- so anything that
+    reasons about "the versions of this series" from the catalog alone
+    can silently omit the one the maintainer is actually on.  Synthesize
+    an entry from the series row when it is missing.
+
+    Every series row, not just one: rescan_branches can leave a change_id
+    with more than one, and the tracking list renders each of them
+    separately.  Resolving only one would enable the range-diff on a
+    version row whose message-id cannot then be found.  Both the DB
+    resolver (:func:`get_revisions_with_tracked`) and the TUI's row
+    builder go through here, so the versions shown and the versions the
+    range-diff can resolve are always the same set.
+
+    Every write path that points a series row at a revision catalogues it
+    first (:func:`_ensure_catalog_row`), and the v11 backfill did the same
+    for every row that predates that rule, so this is a fallback for a
+    database written before it and not a reconciliation step: nothing here
+    fixes up a value the catalog also holds.
+
+    The synthesized entry carries neither blob nor read state.  Both live
+    on the catalog row this one stands in for, so a series row that never
+    got one has none to report -- the entry exists to supply a message-id
+    and a subject, not a badge.
+
+    *series_rows* entries carry ``revision``, ``message_id``, ``subject``,
+    ``found_at``, ``fingerprint`` and ``is_rethreaded``.
+    """
+    revs = list(revs)
+    known = {int(r['revision']) for r in revs if r.get('revision') is not None}
+    added = False
+    for row in series_rows:
+        if not row.get('message_id'):
+            continue
+        tracked = int(row.get('revision') or 1)
+        if tracked in known:
+            continue
+        known.add(tracked)
+        added = True
+        revs.append(
+            {
+                'change_id': change_id,
+                'revision': tracked,
+                'message_id': row['message_id'],
+                'subject': row.get('subject'),
+                'link': '',
+                'found_at': row.get('found_at') or '',
+                'thread_blob': '',
+                'series_blob': '',
+                'fingerprint': row.get('fingerprint'),
+                'source': 'tracked',
+                'is_rethreaded': bool(row.get('is_rethreaded')),
+                'message_count': None,
+                'seen_message_count': None,
+                'last_update_check': None,
+                'last_mail_at': None,
+            }
+        )
+    if added:
+        revs.sort(key=lambda r: r.get('revision') or 0)
+    return revs
+
+
+def get_revisions_with_tracked(
+    conn: sqlite3.Connection, change_id: str
+) -> list[dict[str, Any]]:
+    """get_revisions(), guaranteed to include the revision being tracked.
+
+    :func:`merge_tracked_revisions` over the catalog and every live series
+    row -- see there for why the merge works the way it does.
+    """
+    # Positional access: callers may hand us a connection without a
+    # sqlite3.Row factory (init_db does not set one).
+    rows = [
+        {
+            'revision': row[0],
+            'message_id': row[1],
+            'subject': row[2],
+            'found_at': row[3],
+            'fingerprint': row[4],
+            'is_rethreaded': row[5],
+        }
+        for row in conn.execute(
+            'SELECT revision, message_id, subject, COALESCE(sent_at, added_at),'
+            ' fingerprint, is_rethreaded FROM series'
+            " WHERE change_id = ? AND COALESCE(status, 'new') != 'archived'"
+            ' ORDER BY revision DESC',
+            (change_id,),
+        )
+    ]
+    return merge_tracked_revisions(change_id, get_revisions(conn, change_id), rows)
+
+
 def find_revision_by_fingerprint(
     conn: sqlite3.Connection, fingerprint: Optional[str]
 ) -> Optional[dict[str, Any]]:
diff --git a/src/b4/review_tui/_common.py b/src/b4/review_tui/_common.py
index 34cd5d28..2f161c2c 100644
--- a/src/b4/review_tui/_common.py
+++ b/src/b4/review_tui/_common.py
@@ -1237,7 +1237,11 @@ def compute_range_diff(
     """
     try:
         conn = b4.review.tracking.get_db(identifier)
-        revisions = b4.review.tracking.get_revisions(conn, change_id)
+        # Must include the tracked revision even when the catalog has no
+        # row for it, or the side this diff is taken against cannot be
+        # resolved -- and the TUI enables the action on exactly that
+        # basis.
+        revisions = b4.review.tracking.get_revisions_with_tracked(conn, change_id)
         conn.close()
     except Exception as ex:
         logger.critical('Could not load revisions: %s', ex)
diff --git a/src/b4/review_tui/_tracking_app.py b/src/b4/review_tui/_tracking_app.py
index 31601ded..bcad86f3 100644
--- a/src/b4/review_tui/_tracking_app.py
+++ b/src/b4/review_tui/_tracking_app.py
@@ -1746,6 +1746,40 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
             )
         )
 
+    @staticmethod
+    def _merge_tracked_revision(series: Dict[str, Any]) -> List[Dict[str, Any]]:
+        """Known revisions of a series, guaranteed to include the tracked ones.
+
+        tracking.merge_tracked_revisions() over the preloaded catalog rows
+        and every live series row of the change_id (stashed by
+        _load_series), so the version rows shown here and the revisions
+        compute_range_diff can resolve are always the same set.
+
+        Memoized onto the series dict: the answer gates two bindings, so it
+        is recomputed on every refresh_bindings() for every cursor move.
+        _load_series rebuilds these dicts from scratch, which is what keeps
+        the memo from outliving the data it was derived from.
+        """
+        cached: Optional[List[Dict[str, Any]]] = series.get('_versions')
+        if cached is not None:
+            return cached
+        rows = [
+            {
+                'revision': s.get('revision', 1),
+                'message_id': s.get('message_id', ''),
+                'subject': s.get('subject'),
+                'found_at': s.get('sent_at') or s.get('added_at') or '',
+                'fingerprint': s.get('fingerprint'),
+                'is_rethreaded': s.get('is_rethreaded'),
+            }
+            for s in (series.get('_sibling_rows') or [series])
+        ]
+        revs = b4.review.tracking.merge_tracked_revisions(
+            series.get('change_id', ''), series.get('_revisions') or [], rows
+        )
+        series['_versions'] = revs
+        return revs
+
     def _checkout_new_series(self) -> None:
         """Retrieve series, build am-ready mbox, and show base selection."""
         series = self._selected_series

-- 
2.53.0


^ permalink raw reply related	[flat|nested] 26+ messages in thread

* [PATCH RFC v2 15/25] review-tui: fall back when a cached thread blob has no series
  2026-08-12 21:46 [PATCH RFC v2 00/25] review: track and browse every version of a tracked series Christian Brauner
                   ` (13 preceding siblings ...)
  2026-08-12 21:46 ` [PATCH RFC v2 14/25] review-tui: resolve the tracked revision in revision lists Christian Brauner
@ 2026-08-12 21:46 ` Christian Brauner
  2026-08-12 21:46 ` [PATCH RFC v2 16/25] review-tui: test revision resolution and the range-diff fallback Christian Brauner
                   ` (9 subsequent siblings)
  24 siblings, 0 replies; 26+ messages in thread
From: Christian Brauner @ 2026-08-12 21:46 UTC (permalink / raw)
  To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Christian Brauner (Amutable)

fetch_fake_am_range() trusted revisions.thread_blob unconditionally: any
non-empty blob short-circuited the lore fetch, and if the series could
not be built from it the function gave up rather than trying the path
that would have worked.

That was safe while the column was only ever written from a complete
tracked-series thread at upgrade time.  It is not now that the revision
poller caches whatever thread it counted, which for a version the
backward search records as plain despite broken threading is a single
patch's thread.  Range-diff against such a version then failed
permanently, since the bad blob was preferred on every attempt.

Build the series before committing to the blob, and refetch when it does
not yield one: the lore path runs the get_extra_series() passes that
stitch a broken-threaded version back together.  Store what those passes
produced as the revision's series_blob, beside the thread rather than
over it, since the thread is still what a poll counts and what the next
fetch is diffed against.  The following range-diff reads it back instead
of paying for the stitching again.  An incomplete cached thread is kept
as a fallback for a refetch that cannot run at all.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 src/b4/review_tui/_common.py | 167 +++++++++++++++++++++++++++++++++++--------
 1 file changed, 136 insertions(+), 31 deletions(-)

diff --git a/src/b4/review_tui/_common.py b/src/b4/review_tui/_common.py
index 2f161c2c..b90d6815 100644
--- a/src/b4/review_tui/_common.py
+++ b/src/b4/review_tui/_common.py
@@ -1158,17 +1158,21 @@ def fetch_fake_am_range(
     topdir: str,
     revisions: List[Dict[str, Any]],
     rev: int,
+    recache: Optional[Callable[[int, List[Any]], None]] = None,
 ) -> Optional[Tuple[str, str]]:
     """Fetch a revision and create a fake-am commit range.
 
-    Tries the cached thread blob from the revisions table first,
-    falling back to a lore fetch if the blob is absent or GC'd.
+    Three sources, cheapest first: the stitched ``series_blob`` recorded by
+    an earlier call, the ``thread_blob`` a poll cached when it holds the
+    whole series, and lore.  Any of them may be absent or GC'd.
+
+    *recache*, when given, is handed the stitched messages behind whatever
+    this ends up using so the caller can record them as the revision's
+    series blob.  It is what keeps the next call off the lore path, for a
+    version whose thread does not carry every patch and never will.
 
     Returns (range_start, range_end) on success, or None on failure.
     """
-    msgs = None
-    msgid = ''
-
     # Locate this revision's record
     rev_record: Dict[str, Any] = {}
     for r in revisions:
@@ -1176,36 +1180,120 @@ def fetch_fake_am_range(
             rev_record = r
             break
 
-    # Try cached thread blob first (may be absent or GC'd — tolerate both)
-    blob_sha = rev_record.get('thread_blob') or ''
-    if blob_sha:
-        mbox_bytes = b4.review.tracking.get_thread_mbox(topdir, blob_sha)
-        if mbox_bytes:
-            logger.info('Using cached thread blob for v%d', rev)
-            msgs = b4.split_and_dedupe_pi_results(mbox_bytes)
+    def _series_from(msgs: List[Any]) -> Optional['b4.LoreSeries']:
+        lmbx = b4.LoreMailbox()
+        for msg in msgs:
+            lmbx.add_message(msg)
+        return lmbx.get_series(rev, sloppytrailers=False, codereview_trailers=False)
 
-    # Fall back to lore fetch
-    if not msgs:
-        msgid = rev_record.get('message_id', '')
-        if not msgid:
-            logger.critical('No message-id recorded for v%d', rev)
+    def _known_patches(lser: Optional['b4.LoreSeries']) -> int:
+        """How many of a series' patches are actually in hand.
+
+        Absent counts as none, so the comparisons below need no companion
+        None test: "did this source do better?" is the only question, and
+        having nothing is the bottom of that order rather than a separate
+        case.
+        """
+        if lser is None:
+            return 0
+        return sum(1 for p in (lser.patches or [])[1:] if p is not None)
+
+    def _cached(blob_sha: str) -> Optional[List[Any]]:
+        if not blob_sha:
+            return None
+        mbox_bytes = b4.review.tracking.get_thread_mbox(topdir, blob_sha)
+        if not mbox_bytes:
             return None
+        return b4.split_and_dedupe_pi_results(mbox_bytes)
+
+    # The fullest reconstruction seen so far, whichever source it came
+    # from.  An incomplete range-diff beats none, so this is what the lore
+    # passes below have to improve on to be worth using.
+    partial: Optional['b4.LoreSeries'] = None
+
+    # A recorded series blob is this revision stitched, and it is dropped
+    # the moment its thread changes -- but only a *complete* one is an
+    # answer.  Returning a short stitch unconditionally pinned every later
+    # range-diff to it: a settled version's thread never changes, so the
+    # blob is never dropped and the passes below never run again.  It is
+    # the same completeness test the thread arm applies, for the same
+    # reason.
+    stitched = _cached(rev_record.get('series_blob') or '')
+    if stitched:
+        lser = _series_from(stitched)
+        if lser is not None and lser.complete:
+            logger.info('Using cached series blob for v%d', rev)
+            return _fake_am_range_from(topdir, lser, rev)
+        partial = lser
+
+    # The thread as last fetched, which for most versions is the whole
+    # series and costs nothing further.
+    cached_msgs = _cached(rev_record.get('thread_blob') or '')
+    if cached_msgs:
+        logger.info('Using cached thread blob for v%d', rev)
+        lser = _series_from(cached_msgs)
+        if lser is not None and lser.complete:
+            return _fake_am_range_from(topdir, lser, rev)
+        # get_series() returns None only when every patch is missing; a
+        # merely short series comes back complete=False.  Either way the
+        # thread does not hold the version, and only the get_extra_series()
+        # passes below can stitch the rest back together.
+        if _known_patches(lser) > _known_patches(partial):
+            partial = lser
+        logger.info('Cached thread for v%d is incomplete, refetching', rev)
+
+    msgid = rev_record.get('message_id', '')
+    if not msgid:
+        logger.critical('No message-id recorded for v%d', rev)
+        return _fake_am_range_from(topdir, partial, rev)
 
-        logger.info('Fetching v%d from lore...', rev)
-        with lore_request():
-            msgs = b4.get_pi_thread_by_msgid(msgid)
-            if not msgs:
-                logger.critical('Could not retrieve thread for v%d', rev)
-                return None
+    logger.info('Fetching v%d from lore...', rev)
+    with lore_request():
+        msgs: List[Any] = b4.get_pi_thread_by_msgid(msgid) or []
+        if not msgs:
+            logger.critical('Could not retrieve thread for v%d', rev)
+            return _fake_am_range_from(topdir, partial, rev)
+
+        msgs = b4.mbox.get_extra_series(msgs, direction=1, wantvers=[rev])
+        msgs = b4.mbox.get_extra_series(msgs, direction=-1, wantvers=[rev])
+    lser = _series_from(msgs)
+    if lser is None or _known_patches(lser) <= _known_patches(partial):
+        # The passes came back no more complete than what is already
+        # cached, so that cache is all there is of this version.  Nothing
+        # was stitched here, so nothing is recorded: writing the cached
+        # bytes back as a `series_blob` would file them under a name
+        # meaning "every patch of this version", which is the one thing
+        # they are not -- and the arm above would then trust them.
+        if partial is not None:
+            if lser is None:
+                logger.info(
+                    'Refetch of v%d failed, using the incomplete cached thread', rev
+                )
+            else:
+                logger.info(
+                    'Refetch of v%d is no better than the cache, keeping it', rev
+                )
+        return _fake_am_range_from(topdir, partial, rev)
+
+    # Record what the stitching produced, so the next press of 'd' takes
+    # the branch above instead of three more lore round-trips -- but only
+    # when it is the whole version.  `series_blob` means "every patch of
+    # this version", and its reader returns it without re-deriving that; a
+    # short stitch stays a fallback, and the thread it improved on is
+    # already cached beside it.
+    if recache is not None and msgs and lser.complete:
+        try:
+            recache(rev, msgs)
+        except Exception as ex:
+            logger.debug('Could not re-cache series for v%d: %s', rev, ex)
 
-            msgs = b4.mbox.get_extra_series(msgs, direction=1, wantvers=[rev])
-            msgs = b4.mbox.get_extra_series(msgs, direction=-1, wantvers=[rev])
+    return _fake_am_range_from(topdir, lser, rev)
 
-    lmbx = b4.LoreMailbox()
-    for msg in msgs:
-        lmbx.add_message(msg)
 
-    lser = lmbx.get_series(rev, sloppytrailers=False, codereview_trailers=False)
+def _fake_am_range_from(
+    topdir: str, lser: Optional['b4.LoreSeries'], rev: int
+) -> Optional[Tuple[str, str]]:
+    """Build the fake-am range for *lser*, or fail loudly if there is none."""
     if lser is None:
         logger.critical('Could not find series v%d in retrieved messages', rev)
         return None
@@ -1247,6 +1335,23 @@ def compute_range_diff(
         logger.critical('Could not load revisions: %s', ex)
         return None
 
+    def _recache(rev: int, msgs: List[Any]) -> None:
+        """Store a stitched series so the next range-diff hits the cache.
+
+        Beside the thread blob rather than over it: a poll caches whatever
+        single thread it counted, which for a version posted with broken
+        threading is one patch's, and that thread is still the right
+        baseline for counting new mail even though it will never satisfy
+        fetch_fake_am_range().
+        """
+        conn = b4.review.tracking.get_db(identifier)
+        try:
+            b4.review.tracking.store_revision_series_blob(
+                conn, topdir, change_id, rev, msgs
+            )
+        finally:
+            conn.close()
+
     # --- Resolve the current revision range ---
     # Use local review branch if available, otherwise fetch from lore
     branch = f'b4/review/{change_id}'
@@ -1267,13 +1372,13 @@ def compute_range_diff(
 
     if not cur_start or not cur_end:
         # No local branch — fetch from lore
-        result = fetch_fake_am_range(topdir, revisions, current_rev)
+        result = fetch_fake_am_range(topdir, revisions, current_rev, recache=_recache)
         if result is None:
             return None
         cur_start, cur_end = result
 
     # --- Fetch the other version (blob SHA comes from the revisions table) ---
-    result = fetch_fake_am_range(topdir, revisions, other_rev)
+    result = fetch_fake_am_range(topdir, revisions, other_rev, recache=_recache)
     if result is None:
         return None
     other_start, other_end = result

-- 
2.53.0


^ permalink raw reply related	[flat|nested] 26+ messages in thread

* [PATCH RFC v2 16/25] review-tui: test revision resolution and the range-diff fallback
  2026-08-12 21:46 [PATCH RFC v2 00/25] review: track and browse every version of a tracked series Christian Brauner
                   ` (14 preceding siblings ...)
  2026-08-12 21:46 ` [PATCH RFC v2 15/25] review-tui: fall back when a cached thread blob has no series Christian Brauner
@ 2026-08-12 21:46 ` Christian Brauner
  2026-08-12 21:46 ` [PATCH RFC v2 17/25] review: skip the catalog mirror when nothing moved Christian Brauner
                   ` (8 subsequent siblings)
  24 siblings, 0 replies; 26+ messages in thread
From: Christian Brauner @ 2026-08-12 21:46 UTC (permalink / raw)
  To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Christian Brauner (Amutable)

Cover merge_tracked_revisions() and get_revisions_with_tracked(): the
synthesized entry, every live series row contributing one, and neither
blob nor read state coming across.  Cover fetch_fake_am_range()'s three
sources: the stitched series blob, a cached thread that does hold the
whole series, and the lore refetch for one that does not, including the
incomplete cache kept as a fallback and the stitched result stored back.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 src/tests/test_review_tracking.py | 105 +++++++++++++++
 src/tests/test_tui_review.py      | 274 +++++++++++++++++++++++++++++++++++++-
 2 files changed, 378 insertions(+), 1 deletion(-)

diff --git a/src/tests/test_review_tracking.py b/src/tests/test_review_tracking.py
index e2fcdf85..145fac8f 100644
--- a/src/tests/test_review_tracking.py
+++ b/src/tests/test_review_tracking.py
@@ -8352,3 +8352,108 @@ class TestThreadBlobResolution:
     def test_no_topdir_still_answers(self) -> None:
         assert review_tracking.resolve_thread_blob(None, {'thread-blob': 'x'}) == 'x'
         assert review_tracking.resolve_thread_blob(None, {}) == ''
+
+
+class TestGetRevisionsWithTracked:
+    def test_the_tracked_revision_is_always_present(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """Manually linking a newer version used to record only that one.
+
+        Tracking a series now catalogues the revision it tracks, so the
+        entry is a real row rather than one synthesized on read -- which is
+        also what gives its read state somewhere to live.
+        """
+        conn = review_tracking.init_db('with-tracked')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='[PATCH v2] thing',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-01-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=1,
+        )
+        review_tracking.add_revision(conn, 'cid', 3, 'v3@x')
+        conn.commit()
+        revs = review_tracking.get_revisions_with_tracked(conn, 'cid')
+        conn.close()
+        assert [r['revision'] for r in revs] == [2, 3]
+        tracked = revs[0]
+        assert tracked['message_id'] == 'v2@x'
+
+    def test_two_live_series_rows_both_resolve(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """rescan_branches can leave a change_id with two live rows.
+
+        The tracking list renders a row per live series row, and every
+        gate that offers the range-diff counts the same way, so both
+        revisions have to be resolvable here -- picking one would enable
+        an action on a version this function cannot find a message-id for.
+        """
+        conn = review_tracking.init_db('tracked-ambiguous')
+        for rev in (2, 5):
+            conn.execute(
+                'INSERT INTO series (change_id, revision, message_id, subject,'
+                " sender_name, sender_email, status) VALUES (?,?,?,?,?,?,'new')",
+                ('cid', rev, f'v{rev}@x', f'subj v{rev}', 'A', 'a@x'),
+            )
+        conn.commit()
+        revs = review_tracking.get_revisions_with_tracked(conn, 'cid')
+        conn.close()
+        assert [r['revision'] for r in revs] == [2, 5]
+        assert [r['message_id'] for r in revs] == ['v2@x', 'v5@x']
+
+    def test_present_tracked_revision_is_left_alone(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        conn = review_tracking.init_db('with-tracked-noop')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='[PATCH v2] thing',
+            sender_name='S',
+            sender_email='s@e.com',
+            sent_at='2026-01-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=1,
+        )
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+        conn.commit()
+        revs = review_tracking.get_revisions_with_tracked(conn, 'cid')
+        conn.close()
+        assert [r['revision'] for r in revs] == [2]
+        assert revs[0]['source'] == 'heuristic'
+
+
+class TestMergeTrackedRevisionsHelper:
+    """One merge rule for the DB resolver and the TUI's version rows."""
+
+    def test_every_series_row_resolves(self) -> None:
+        revs = [
+            {
+                'change_id': 'cid',
+                'revision': 2,
+                'message_id': 'v2@x',
+                'message_count': 4,
+            }
+        ]
+        rows = [
+            {'revision': 3, 'message_id': 'v3@x', 'subject': 's3'},
+            {'revision': 2, 'message_id': 'v2@x', 'subject': 's2'},
+            {'revision': 1, 'message_id': ''},
+        ]
+        merged = review_tracking.merge_tracked_revisions('cid', revs, rows)
+        assert [r['revision'] for r in merged] == [2, 3]
+        # The catalog row wins over a synthesized twin.
+        assert merged[0]['message_count'] == 4
+        synth = merged[1]
+        assert synth['source'] == 'tracked'
+        assert synth['message_id'] == 'v3@x'
+        # No read state: the entry supplies a message-id, not a badge.
+        assert synth['message_count'] is None
+        assert synth['seen_message_count'] is None
diff --git a/src/tests/test_tui_review.py b/src/tests/test_tui_review.py
index 8c4ce039..641b61c8 100644
--- a/src/tests/test_tui_review.py
+++ b/src/tests/test_tui_review.py
@@ -10,7 +10,7 @@ cosmetic commit edits (e.g. reworded subjects via git rebase -i).
 """
 
 import json
-from typing import Any, Dict, List, Tuple
+from typing import Any, Dict, List, Optional, Tuple
 from unittest import mock
 
 import pytest
@@ -18,6 +18,7 @@ import pytest
 pytest.importorskip('textual')
 
 import b4
+import b4.mbox
 import b4.review
 import b4.review.tracking
 from b4.review_tui._review_app import ReviewApp
@@ -995,3 +996,274 @@ class TestRangeDiffBindingGate:
         # Range-diff is a review-mode action; email mode hides it
         app._preview_mode = True
         assert app.check_action('range_diff', ()) is False
+
+
+class TestIncompleteCachedThreadBlob:
+    """A blob the poller cached may hold only part of a series."""
+
+    @staticmethod
+    def _revisions() -> List[Dict[str, Any]]:
+        return [
+            {
+                'revision': 1,
+                'message_id': 'x@example.com',
+                'thread_blob': 'cafebabe',
+            }
+        ]
+
+    @staticmethod
+    def _series(complete: bool, patches: int) -> mock.Mock:
+        """A LoreSeries stub holding *patches* of its patches."""
+        lser = mock.Mock()
+        lser.complete = complete
+        # patches[0] is the cover slot, which _known_patches skips.
+        lser.patches = [None] + [mock.Mock() for _ in range(patches)]
+        lser.make_fake_am_range.return_value = ('start', 'end')
+        return lser
+
+    def _patch_blob(
+        self,
+        monkeypatch: pytest.MonkeyPatch,
+        complete: bool,
+        patches: int = 1,
+        then: Optional[List[mock.Mock]] = None,
+    ) -> mock.Mock:
+        """Make the cached blob decode to a series with the given completeness.
+
+        *then*, when given, is what the later _series_from() calls decode
+        to, in order -- a series blob, the thread blob and the lore refetch
+        are all parsed by the same helper, so each needs its own
+        LoreMailbox result.
+        """
+        lser = self._series(complete, patches)
+        monkeypatch.setattr(
+            b4.review.tracking, 'get_thread_mbox', lambda topdir, sha: b'From x\n'
+        )
+        monkeypatch.setattr(b4, 'split_and_dedupe_pi_results', lambda raw: ['m'])
+        results = [lser] + list(then or [])
+        lmbx = mock.Mock()
+        lmbx.get_series.side_effect = lambda *a, **kw: (
+            results.pop(0) if len(results) > 1 else results[0]
+        )
+        monkeypatch.setattr(b4, 'LoreMailbox', lambda: lmbx)
+        return lser
+
+    def test_an_incomplete_blob_is_used_when_the_refetch_fails(
+        self, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """An incomplete range-diff beats none.
+
+        Discarding the blob outright turned a 'd' that used to work into a
+        silent failure whenever the refetch could not run -- offline, lore
+        down, or a message-id that 404s.
+        """
+        from b4.review_tui._common import fetch_fake_am_range
+
+        lser = self._patch_blob(monkeypatch, complete=False)
+        monkeypatch.setattr(b4, 'get_lore_node', lambda: mock.Mock())
+        monkeypatch.setattr(b4, 'get_pi_thread_by_msgid', lambda msgid, **kw: None)
+
+        assert fetch_fake_am_range('/nonexistent', self._revisions(), 1) == (
+            'start',
+            'end',
+        )
+        assert lser.make_fake_am_range.called
+
+    def test_a_more_complete_refetch_replaces_the_cached_blob(
+        self, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Otherwise the same refetch repeats on every press of 'd'.
+
+        The poller re-stores what it counted, so nothing else ever replaces
+        a blob holding one patch's thread.
+        """
+        from b4.review_tui._common import fetch_fake_am_range
+
+        better = self._series(complete=True, patches=4)
+        self._patch_blob(monkeypatch, complete=False, patches=1, then=[better])
+        monkeypatch.setattr(b4, 'get_lore_node', lambda: mock.Mock())
+        monkeypatch.setattr(
+            b4, 'get_pi_thread_by_msgid', lambda msgid, **kw: ['a', 'b']
+        )
+        monkeypatch.setattr(
+            b4.mbox,
+            'get_extra_series',
+            lambda msgs, direction=1, wantvers=None, nocache=False: list(msgs),
+        )
+        recached: List[Tuple[int, int]] = []
+        fetch_fake_am_range(
+            '/nonexistent',
+            self._revisions(),
+            1,
+            recache=lambda rev, msgs: recached.append((rev, len(msgs))),
+        )
+        assert recached == [(1, 2)]
+        assert better.make_fake_am_range.called
+
+    def test_a_refetch_that_is_no_better_is_not_recorded_as_a_stitch(
+        self, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """A short refetch must not decide the range-diff -- or be cached.
+
+        Lore can truncate, and a rethreaded version's per-patch queries can
+        come back incomplete, so the fuller cached thread is what this
+        range-diff is built from.  Those same bytes must not then be
+        written back as the version's series blob: nothing was stitched,
+        and `series_blob` means "every patch of this version" -- which is
+        exactly what the thread arm has just found they are not.  Filed
+        under that name they are read back as an answer, and since a
+        settled version's thread never changes again, nothing ever drops
+        them.
+        """
+        from b4.review_tui._common import fetch_fake_am_range
+
+        worse = self._series(complete=False, patches=1)
+        cached = self._patch_blob(monkeypatch, complete=False, patches=3, then=[worse])
+        monkeypatch.setattr(b4, 'get_lore_node', lambda: mock.Mock())
+        monkeypatch.setattr(
+            b4, 'get_pi_thread_by_msgid', lambda msgid, **kw: ['a', 'b']
+        )
+        monkeypatch.setattr(
+            b4.mbox,
+            'get_extra_series',
+            lambda msgs, direction=1, wantvers=None, nocache=False: list(msgs),
+        )
+        recached: List[Tuple[int, List[Any]]] = []
+        assert fetch_fake_am_range(
+            '/nonexistent',
+            self._revisions(),
+            1,
+            recache=lambda rev, msgs: recached.append((rev, msgs)),
+        ) == ('start', 'end')
+        # Nothing was stitched, so nothing is recorded as a stitch.
+        assert recached == []
+        # And the range-diff is built from the cache, not the short refetch.
+        assert cached.make_fake_am_range.called
+        assert not worse.make_fake_am_range.called
+
+    def test_an_incomplete_improvement_is_used_but_not_recorded(
+        self, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Fuller than the thread is still not every patch.
+
+        Using it is right -- it is the best reconstruction there is -- but
+        recording it would let the series-blob arm hand it back as the
+        whole version on the next press, without the completeness test
+        that just judged it short.
+        """
+        from b4.review_tui._common import fetch_fake_am_range
+
+        better = self._series(complete=False, patches=3)
+        self._patch_blob(monkeypatch, complete=False, patches=1, then=[better])
+        monkeypatch.setattr(b4, 'get_lore_node', lambda: mock.Mock())
+        monkeypatch.setattr(
+            b4, 'get_pi_thread_by_msgid', lambda msgid, **kw: ['a', 'b']
+        )
+        monkeypatch.setattr(
+            b4.mbox,
+            'get_extra_series',
+            lambda msgs, direction=1, wantvers=None, nocache=False: list(msgs),
+        )
+        recached: List[int] = []
+        assert fetch_fake_am_range(
+            '/nonexistent',
+            self._revisions(),
+            1,
+            recache=lambda rev, msgs: recached.append(rev),
+        ) == ('start', 'end')
+        assert better.make_fake_am_range.called
+        assert recached == []
+
+    def test_an_incomplete_series_blob_is_a_fallback_not_an_answer(
+        self, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Otherwise a short stitch pins every later range-diff to itself.
+
+        The blob is dropped when its thread changes, and a settled old
+        version's thread never does -- so trusting a short stitch here is
+        for ever, and silently: the log line reads 'using cached series
+        blob' either way, while the thread arm would have said the version
+        was incomplete and refetched.
+        """
+        from b4.review_tui._common import fetch_fake_am_range
+
+        thread = self._series(complete=False, patches=1)
+        better = self._series(complete=True, patches=4)
+        stitched = self._patch_blob(
+            monkeypatch, complete=False, patches=2, then=[thread, better]
+        )
+        monkeypatch.setattr(b4, 'get_lore_node', lambda: mock.Mock())
+        monkeypatch.setattr(
+            b4, 'get_pi_thread_by_msgid', lambda msgid, **kw: ['a', 'b']
+        )
+        monkeypatch.setattr(
+            b4.mbox,
+            'get_extra_series',
+            lambda msgs, direction=1, wantvers=None, nocache=False: list(msgs),
+        )
+        revisions = self._revisions()
+        revisions[0]['series_blob'] = 'deadbeef'
+        recached: List[int] = []
+        assert fetch_fake_am_range(
+            '/nonexistent',
+            revisions,
+            1,
+            recache=lambda rev, msgs: recached.append(rev),
+        ) == ('start', 'end')
+        assert better.make_fake_am_range.called
+        assert not stitched.make_fake_am_range.called
+        assert recached == [1]
+
+    def test_a_complete_series_blob_short_circuits_everything(
+        self, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """A whole version, already stitched, cannot be improved on.
+
+        It was stored because the thread would not do, and it is dropped
+        the moment that thread changes, so paying for the stitching passes
+        again buys nothing.
+        """
+        from b4.review_tui._common import fetch_fake_am_range
+
+        stitched = self._patch_blob(monkeypatch, complete=True, patches=2)
+        fetched: List[str] = []
+        monkeypatch.setattr(b4, 'get_lore_node', lambda: mock.Mock())
+        monkeypatch.setattr(
+            b4, 'get_pi_thread_by_msgid', lambda msgid, **kw: fetched.append(msgid)
+        )
+        revisions = self._revisions()
+        revisions[0]['series_blob'] = 'deadbeef'
+        recached: List[int] = []
+        assert fetch_fake_am_range(
+            '/nonexistent',
+            revisions,
+            1,
+            recache=lambda rev, msgs: recached.append(rev),
+        ) == ('start', 'end')
+        assert stitched.make_fake_am_range.called
+        assert fetched == []
+        assert recached == []
+
+    def test_a_complete_blob_is_not_refetched(
+        self, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """The cache still has to hit in the normal case."""
+        from b4.review_tui._common import fetch_fake_am_range
+
+        self._patch_blob(monkeypatch, complete=True)
+        fetched: List[str] = []
+        monkeypatch.setattr(b4, 'get_lore_node', lambda: mock.Mock())
+        monkeypatch.setattr(
+            b4,
+            'get_pi_thread_by_msgid',
+            lambda msgid, **kw: fetched.append(msgid),
+        )
+        recached: List[int] = []
+        assert fetch_fake_am_range(
+            '/nonexistent',
+            self._revisions(),
+            1,
+            recache=lambda rev, msgs: recached.append(rev),
+        ) == ('start', 'end')
+        assert fetched == []
+        assert recached == []

-- 
2.53.0


^ permalink raw reply related	[flat|nested] 26+ messages in thread

* [PATCH RFC v2 17/25] review: skip the catalog mirror when nothing moved
  2026-08-12 21:46 [PATCH RFC v2 00/25] review: track and browse every version of a tracked series Christian Brauner
                   ` (15 preceding siblings ...)
  2026-08-12 21:46 ` [PATCH RFC v2 16/25] review-tui: test revision resolution and the range-diff fallback Christian Brauner
@ 2026-08-12 21:46 ` Christian Brauner
  2026-08-12 21:46 ` [PATCH RFC v2 18/25] review: match a stray posting by message-id Christian Brauner
                   ` (7 subsequent siblings)
  24 siblings, 0 replies; 26+ messages in thread
From: Christian Brauner @ 2026-08-12 21:46 UTC (permalink / raw)
  To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Christian Brauner (Amutable)

sync_revisions_catalog_to_branch() is a no-op in the steady state, and
the update sweep lands there every pass for every series, so answering
"already current" should cost nothing.

Two things drive that.  The sweep only mirrors the catalog as part of a
branch save for four statuses, so every other status never mirrored at
all, even though those series still hold per-patch message-ids that
cannot be re-derived from the list; mirror them here instead of never.
Answering then has to stop costing a rev-parse plus a tracking-commit
read per series per pass.

changes.catalog_synced remembers "<branch-sha>:<catalog-sha1>" from the
last verified mirror.  While both halves still match, the branch reads
are skipped outright; rescan_branches() refreshes branch_sha at the start
of every sweep, and a pulled or rewritten branch changes it.  Any catalog
write or branch move breaks the pair and falls through to the full
compare-and-save.

The pair is a cache key.  A mismatch costs one extra tracking-commit
read, never a wrong answer.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 src/b4/review/_review.py     | 40 +++++++++++++++++-
 src/b4/review/tracking.py    | 97 ++++++++++++++++++++++++++++++++++++--------
 src/tests/test_review.py     | 19 +++++----
 src/tests/test_tui_modals.py |  1 +
 4 files changed, 131 insertions(+), 26 deletions(-)

diff --git a/src/b4/review/_review.py b/src/b4/review/_review.py
index 652f21a5..68b5a7a0 100644
--- a/src/b4/review/_review.py
+++ b/src/b4/review/_review.py
@@ -2452,9 +2452,18 @@ def update_series_tracking(
     identifier: str,
     linkmask: str,
     topdir: Optional[str] = None,
+    review_branches: Optional[Set[str]] = None,
 ) -> Dict[str, Any]:
     """Fetch thread, discover revisions, update trailers for one series.
 
+    *review_branches*, when given, is every ``b4/review/*`` branch that
+    exists, as :func:`b4.review.tracking.rescan_branches` enumerated it at
+    the top of this sweep.  It answers "has this change_id a branch at
+    all?" without a subprocess, which the catalog mirror below would
+    otherwise ask git once per series and then have answered "no" for
+    every series that has never been checked out.  None means the caller
+    does not know, and the mirror falls back to asking.
+
     Returns {'new_revisions': int, 'new_trailers': int,
              'error': Optional[str]}.
     """
@@ -2581,6 +2590,11 @@ def update_series_tracking(
     # have already happened; the tracking commit catches up on the next
     # sweep that finds the worktree free.
     branch = f'b4/review/{change_id}'
+    # Whether there is a branch to write to at all, from the enumeration the
+    # sweep already did.  Believing a stale "yes" costs one sync that
+    # declines; a stale "no" defers the mirror by one sweep, and nothing
+    # creates a review branch while a sweep is running.
+    has_review_branch = review_branches is None or branch in review_branches
     # Asked only for the statuses whose branch this would write, and only
     # to *report* the skip -- save_tracking_ref declines on its own, so
     # nothing below depends on getting this right.  Every other status
@@ -2671,6 +2685,21 @@ def update_series_tracking(
             result['error'] = 'Error saving tracking data'
             return result
 
+    elif topdir and has_review_branch:
+        # The block above mirrors the catalog as part of its save, but it
+        # only runs for four statuses.  Every other one still holds
+        # per-patch message-ids that cannot be re-derived from the list, so
+        # mirror them here rather than never.  Cheap on the every-sweep
+        # steady state: the sync's catalog_synced watermark answers
+        # "already current" without touching git -- but only for a series
+        # that has a branch.  Without one there is no watermark to match
+        # and nothing to mirror onto, so the sync opens the database and
+        # spends a rev-parse every sweep only to decline.  A 'new' series
+        # is exactly that case, and a tracking list is mostly those.
+        b4.review.tracking.sync_revisions_catalog_to_branch(
+            topdir, identifier, change_id
+        )
+
     # Auto-mark the maintainer's own messages as read.  Two passes:
     # replies sent through b4 were already flagged Seen at send time and
     # match here by message-id; anything whose From exactly matches the
@@ -2795,10 +2824,15 @@ def update_all_tracking(
     with b4.lockfile_nb(_get_update_lock_path(identifier)):
         # Rescan local review branches first so the DB reflects current
         # on-disk state before the network update runs.
+        # The review branches that exist, enumerated once here rather than
+        # asked per series below.  Left None when the rescan could not run,
+        # which is the "do not know" the per-series check falls back on.
+        review_branches: Optional[Set[str]] = None
         if topdir:
             try:
                 rescan = b4.review.tracking.rescan_branches(identifier, topdir)
                 result['gone'] = rescan.get('gone', 0)
+                review_branches = rescan.get('branches')
             except Exception as ex:
                 logger.warning('Pre-update rescan failed: %s', ex)
 
@@ -2823,7 +2857,11 @@ def update_all_tracking(
                 # Called via the package attribute: it is the established
                 # patch seam for tests and TUI callers alike
                 r = b4.review.update_series_tracking(
-                    series, identifier, linkmask, topdir=topdir
+                    series,
+                    identifier,
+                    linkmask,
+                    topdir=topdir,
+                    review_branches=review_branches,
                 )
             except liblore.OperationCancelledError:
                 result['cancelled'] = True
diff --git a/src/b4/review/tracking.py b/src/b4/review/tracking.py
index c8d9e7d9..958b5180 100644
--- a/src/b4/review/tracking.py
+++ b/src/b4/review/tracking.py
@@ -8,6 +8,7 @@ __author__ = 'Konstantin Ryabitsev <konstantin@linuxfoundation.org>'
 import argparse
 import datetime
 import email.utils
+import hashlib
 import json
 import os
 import pathlib
@@ -1521,6 +1522,11 @@ def record_known_revisions(
     conn.commit()
 
 
+def _catalog_sha(known: List[Dict[str, Any]]) -> str:
+    """Content hash of a serialized revisions catalog, for the sync watermark."""
+    return hashlib.sha1(json.dumps(known, sort_keys=True).encode('utf-8')).hexdigest()
+
+
 def _set_change_state(conn: sqlite3.Connection, change_id: str, **cols: Any) -> None:
     """Upsert per-change_id state, creating the `changes` row if needed.
 
@@ -1567,6 +1573,21 @@ def forget_change_state(conn: sqlite3.Connection, change_id: str) -> None:
     conn.execute('DELETE FROM changes WHERE change_id = ?', (change_id,))
 
 
+def _store_catalog_synced(
+    conn: sqlite3.Connection, change_id: str, branch_sha: str, catalog_sha: str
+) -> None:
+    """Record a verified known-revisions mirror for the sync fast path.
+
+    The branch sha goes into the watermark and nowhere else.  `branch_sha`
+    means "the sha whose tracking commit the database has imported", and
+    only rescan_branches can say that; writing it here -- as a mirror of a
+    branch this side has only read -- made rescan_branches skip a branch it
+    had never imported, so a pushed status and its known-revisions block
+    were never replayed.  One column, one writer, one meaning.
+    """
+    _set_change_state(conn, change_id, catalog_synced=f'{branch_sha}:{catalog_sha}')
+
+
 def sync_revisions_catalog_to_branch(
     topdir: Optional[str], identifier: str, change_id: str
 ) -> bool:
@@ -1578,6 +1599,17 @@ def sync_revisions_catalog_to_branch(
     No-op (returns False) when there is no topdir, no such branch, or the
     catalog is already current.
 
+    The steady state is "already current", and the update sweep lands here
+    every pass for every series whose branch save does not mirror the
+    catalog itself -- accepted and thanked ones included -- so answering
+    must not cost git subprocesses.  ``changes.catalog_synced`` remembers
+    ``<branch-sha>:<catalog-sha1>`` from the last verified mirror: while
+    both sides still match (rescan_branches refreshes ``branch_sha`` at
+    the start of every sweep, and a pulled or rewritten branch changes
+    it), the branch reads are skipped outright.  Any catalog write or
+    branch move breaks the pair and falls through to the full
+    compare-and-save.
+
     A branch whose worktree is mid-operation is declined by
     :func:`b4.review.save_tracking_ref` itself, so there is no test for it
     here: the rule belongs to the write, and repeating it per caller is
@@ -1590,22 +1622,45 @@ def sync_revisions_catalog_to_branch(
     import b4.review
 
     branch = f'b4/review/{change_id}'
-    ecode, _ = b4.git_run_command(topdir, ['rev-parse', '--verify', branch])
-    if ecode != 0:
-        return False
-    try:
-        cover_text, tracking = b4.review.load_tracking(topdir, branch)
-    except (SystemExit, Exception):
-        return False
     conn = get_db(identifier)
+    known: Optional[List[Dict[str, Any]]] = None
+    catalog_sha = ''
     try:
-        known = build_known_revisions(conn, change_id)
+        row = conn.execute(
+            'SELECT branch_sha, catalog_synced FROM changes WHERE change_id = ?',
+            (change_id,),
+        ).fetchone()
+        if row is not None and row[0] and row[1]:
+            known = build_known_revisions(conn, change_id)
+            catalog_sha = _catalog_sha(known)
+            if row[1] == f'{row[0]}:{catalog_sha}':
+                return False
+
+        ecode, out = b4.git_run_command(topdir, ['rev-parse', '--verify', branch])
+        if ecode != 0:
+            return False
+        branch_sha = str(out.strip())
+        try:
+            cover_text, tracking = b4.review.load_tracking(topdir, branch)
+        except (SystemExit, Exception):
+            return False
+        if known is None:
+            known = build_known_revisions(conn, change_id)
+            catalog_sha = _catalog_sha(known)
+        if tracking.get('known-revisions') == known:
+            _store_catalog_synced(conn, change_id, branch_sha, catalog_sha)
+            return False
+        tracking['known-revisions'] = known
+        if not b4.review.save_tracking_ref(topdir, branch, cover_text, tracking):
+            return False
+        # The save just moved the branch; record the sha it moved to, so the
+        # next sweep matches without waiting for a rescan.
+        ecode, out = b4.git_run_command(topdir, ['rev-parse', '--verify', branch])
+        if ecode == 0:
+            _store_catalog_synced(conn, change_id, str(out.strip()), catalog_sha)
+        return True
     finally:
         conn.close()
-    if tracking.get('known-revisions') == known:
-        return False
-    tracking['known-revisions'] = known
-    return bool(b4.review.save_tracking_ref(topdir, branch, cover_text, tracking))
 
 
 _REVISION_COLS = (
@@ -4209,7 +4264,7 @@ def refresh_message_count(
 
 def rescan_branches(
     identifier: str, topdir: str, branch: Optional[str] = None
-) -> Dict[str, int]:
+) -> Dict[str, Any]:
     """Rescan review branches and sync status/metadata into the tracking DB.
 
     Iterates b4/review/* branches (or a single branch if specified).  For each
@@ -4219,8 +4274,14 @@ def rescan_branches(
     upserted.  When doing a full rescan (branch=None), series whose branches
     have disappeared are marked as 'gone'.
 
-    Returns ``{'gone': n, 'changed': n}`` where ``changed`` is the number of
-    branches whose SHA differed and were re-processed.
+    Returns ``{'gone': n, 'changed': n, 'branches': set-or-None}`` where
+    ``changed`` is the number of branches whose SHA differed and were
+    re-processed, and ``branches`` is every review branch this rescan
+    enumerated.  A sweep is a long series of per-series decisions that each
+    want to know whether a change_id has a branch at all, and this is the
+    one place that already asked git; ``None`` when *branch* narrowed the
+    rescan to one, because a one-element answer must not be read as "the
+    only review branch there is".
     """
     import b4.review
 
@@ -4361,7 +4422,11 @@ def rescan_branches(
                     gone += 1
 
     conn.close()
-    return {'gone': gone, 'changed': changed}
+    return {
+        'gone': gone,
+        'changed': changed,
+        'branches': None if branch else set(branches),
+    }
 
 
 def delete_series(
diff --git a/src/tests/test_review.py b/src/tests/test_review.py
index a7c9cb15..f3adcb5a 100644
--- a/src/tests/test_review.py
+++ b/src/tests/test_review.py
@@ -4503,6 +4503,7 @@ def test_update_all_tracking_skips_snoozed_and_archived(
         identifier: str,
         linkmask: str,
         topdir: Optional[str] = None,
+        **kw: Any,
     ) -> Dict[str, Any]:
         updated.append(one['change_id'])
         if one['change_id'] == 'd':
@@ -4878,7 +4879,7 @@ def test_update_all_tracking_polls_revisions_capped(
     monkeypatch.setattr(
         review,
         'update_series_tracking',
-        lambda one, identifier, linkmask, topdir=None: {
+        lambda one, identifier, linkmask, topdir=None, **kw: {
             'new_revisions': 0,
             'new_trailers': 0,
             'error': None,
@@ -4942,7 +4943,7 @@ def test_update_all_tracking_reports_revision_poll_errors(
     monkeypatch.setattr(
         review,
         'update_series_tracking',
-        lambda one, identifier, linkmask, topdir=None: {
+        lambda one, identifier, linkmask, topdir=None, **kw: {
             'new_revisions': 0,
             'new_trailers': 0,
             'error': None,
@@ -4974,7 +4975,7 @@ def test_update_all_tracking_feeds_poll_progress(
     monkeypatch.setattr(
         review,
         'update_series_tracking',
-        lambda one, identifier, linkmask, topdir=None: {
+        lambda one, identifier, linkmask, topdir=None, **kw: {
             'new_revisions': 0,
             'new_trailers': 0,
             'error': None,
@@ -5077,7 +5078,7 @@ def test_update_all_tracking_cancelled_poller_stops_sweep(
     monkeypatch.setattr(
         review,
         'update_series_tracking',
-        lambda one, identifier, linkmask, topdir=None: {
+        lambda one, identifier, linkmask, topdir=None, **kw: {
             'new_revisions': 0,
             'new_trailers': 0,
             'error': None,
@@ -5101,7 +5102,7 @@ def _stub_sweep(monkeypatch: pytest.MonkeyPatch, series: List[Dict[str, Any]]) -
     monkeypatch.setattr(
         review,
         'update_series_tracking',
-        lambda one, identifier, linkmask, topdir=None: {
+        lambda one, identifier, linkmask, topdir=None, **kw: {
             'new_revisions': 0,
             'new_trailers': 0,
             'error': None,
@@ -5129,7 +5130,7 @@ def test_a_busy_branch_still_polls_its_revisions(
     monkeypatch.setattr(
         review,
         'update_series_tracking',
-        lambda one, identifier, linkmask, topdir=None: {
+        lambda one, identifier, linkmask, topdir=None, **kw: {
             'new_revisions': 0,
             'new_trailers': 0,
             'error': None,
@@ -5167,7 +5168,7 @@ def test_update_all_tracking_forwards_the_forced_poll(
     monkeypatch.setattr(
         review,
         'update_series_tracking',
-        lambda one, identifier, linkmask, topdir=None: {
+        lambda one, identifier, linkmask, topdir=None, **kw: {
             'new_revisions': 0,
             'new_trailers': 0,
             'error': None,
@@ -5416,7 +5417,7 @@ class TestCancelledPollKeepsItsTally:
         monkeypatch.setattr(
             review,
             'update_series_tracking',
-            lambda one, identifier, linkmask, topdir=None: {
+            lambda one, identifier, linkmask, topdir=None, **kw: {
                 'new_revisions': 0,
                 'new_trailers': 0,
                 'error': None,
@@ -5500,7 +5501,7 @@ class TestExplicitUpdateReachesASnoozedSeries:
         monkeypatch.setattr(
             review,
             'update_series_tracking',
-            lambda one, ident, linkmask, topdir=None: {
+            lambda one, ident, linkmask, topdir=None, **kw: {
                 'new_revisions': 0,
                 'new_trailers': 0,
                 'error': None,
diff --git a/src/tests/test_tui_modals.py b/src/tests/test_tui_modals.py
index 5a19cdd4..46b4d8b7 100644
--- a/src/tests/test_tui_modals.py
+++ b/src/tests/test_tui_modals.py
@@ -1132,6 +1132,7 @@ class TestUpdateAllScreenCancellation:
             identifier: str,
             linkmask: str,
             topdir: Optional[str] = None,
+            **kw: Any,
         ) -> Dict[str, Any]:
             nonlocal call_count
             call_count += 1

-- 
2.53.0


^ permalink raw reply related	[flat|nested] 26+ messages in thread

* [PATCH RFC v2 18/25] review: match a stray posting by message-id
  2026-08-12 21:46 [PATCH RFC v2 00/25] review: track and browse every version of a tracked series Christian Brauner
                   ` (16 preceding siblings ...)
  2026-08-12 21:46 ` [PATCH RFC v2 17/25] review: skip the catalog mirror when nothing moved Christian Brauner
@ 2026-08-12 21:46 ` Christian Brauner
  2026-08-12 21:47 ` [PATCH RFC v2 19/25] review: add backward discovery of older series revisions Christian Brauner
                   ` (6 subsequent siblings)
  24 siblings, 0 replies; 26+ messages in thread
From: Christian Brauner @ 2026-08-12 21:46 UTC (permalink / raw)
  To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Christian Brauner (Amutable)

Three places ask whether another series already owns a posting: [l]'s
confirmation preview, the absorb it then performs, and the conflicts [o]
reports.  They disagreed, and the preview warned about the safe cases
while staying silent on the destructive one.

Give them one rule.  find_stray_revision() matches by message-id first
and fingerprint second.  A fingerprint hashes only the patches present,
so a revision recorded from a partial fetch never matches the one
computed with the whole series in hand.

Both lookups exclude the target change_id in SQL and walk every remaining
row rather than testing one.  A posting can sit in the catalog under the
link target as well as under the stray, since auto-discovery records it
and the maintainer then links it, so taking the first row back would
answer with the target itself whenever it sorts first.  [l] would then
duplicate the series instead of absorbing it.

An all-archived owner is not a match at all.  It is invisible in the
tracking list, so absorbing it deletes a series the maintainer cannot see
and reporting it sends them after one they cannot reach.  The same walk
keeps such an owner from hiding a live one behind it.

Act on the absorb result rather than assuming it.  Absorb still declines
a revision with neither a series nor a catalog row, and that revision
would otherwise go unrecorded while the TUI reported both a link and an
absorbed duplicate.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 src/b4/review/tracking.py          | 111 +++++++++++++++++++++++++++++++------
 src/b4/review_tui/_tracking_app.py |  10 +++-
 2 files changed, 102 insertions(+), 19 deletions(-)

diff --git a/src/b4/review/tracking.py b/src/b4/review/tracking.py
index 958b5180..6662cd9e 100644
--- a/src/b4/review/tracking.py
+++ b/src/b4/review/tracking.py
@@ -1804,6 +1804,32 @@ def get_revisions_with_tracked(
     return merge_tracked_revisions(change_id, get_revisions(conn, change_id), rows)
 
 
+def _find_revisions_by(
+    conn: sqlite3.Connection,
+    column: str,
+    value: Optional[str],
+    exclude_change_id: Optional[str] = None,
+) -> List[dict[str, Any]]:
+    """Every catalog row whose *column* equals *value*, ordered by change_id.
+
+    Plural on purpose: nothing constrains a message-id or a fingerprint to
+    one change_id, so a caller looking for *another* series' copy has to be
+    able to walk past the first row.  Ordered, or the answer is rowid order
+    and flips the first time a row is rewritten.
+
+    *column* is a literal supplied by this module, never caller input.
+    """
+    if not value:
+        return []
+    sql = _REVISION_SELECT + f' WHERE r.{column} = ?'
+    params: List[Any] = [value]
+    if exclude_change_id is not None:
+        sql += ' AND r.change_id != ?'
+        params.append(exclude_change_id)
+    sql += ' ORDER BY r.change_id'
+    return [dict(zip(_REVISION_COLS, row)) for row in conn.execute(sql, params)]
+
+
 def find_revision_by_fingerprint(
     conn: sqlite3.Connection, fingerprint: Optional[str]
 ) -> Optional[dict[str, Any]]:
@@ -1813,15 +1839,43 @@ def find_revision_by_fingerprint(
     under a different change_id) so it can be absorbed rather than duplicated.
     An empty or None fingerprint never matches.
     """
-    if not fingerprint:
-        return None
-    row = conn.execute(
-        _REVISION_SELECT + ' WHERE r.fingerprint = ? ORDER BY r.change_id LIMIT 1',
-        (fingerprint,),
-    ).fetchone()
-    if row is None:
-        return None
-    return dict(zip(_REVISION_COLS, row))
+    rows = _find_revisions_by(conn, 'fingerprint', fingerprint)
+    return rows[0] if rows else None
+
+
+def find_stray_revision(
+    conn: sqlite3.Connection,
+    change_id: str,
+    message_id: Optional[str],
+    fingerprint: Optional[str],
+) -> Optional[dict[str, Any]]:
+    """Return the revision another series already owns this posting under.
+
+    The single rule behind [l]'s confirmation preview, the absorb it then
+    performs, and the conflicts [o] reports -- they disagreed once and the
+    preview warned about the safe cases while staying silent on the
+    destructive one.
+
+    Message-id first, fingerprint second: a fingerprint hashes only the
+    patches present, so a revision recorded from a partial fetch does not
+    match the one computed with the whole series in hand.  An all-archived
+    owner is not a match at all -- it is invisible in the tracking list, so
+    absorbing it deletes a series the maintainer cannot see and reporting
+    it sends them after one they cannot reach.
+
+    Both lookups exclude *change_id* in SQL and walk every remaining row
+    rather than testing one.  A posting can sit in the catalog under the
+    link target as well as under the stray -- auto-discovery records it,
+    then the maintainer links it -- and taking the first row back would
+    answer with the target itself whenever it sorts first, reporting no
+    stray and letting [l] duplicate the series instead of absorbing it.
+    The same walk keeps an archived owner from hiding a live one behind it.
+    """
+    for column, value in (('message_id', message_id), ('fingerprint', fingerprint)):
+        for stray in _find_revisions_by(conn, column, value, change_id):
+            if not _is_archived_only(conn, str(stray['change_id'])):
+                return stray
+    return None
 
 
 def find_existing_change_id(
@@ -2251,8 +2305,10 @@ def record_linked_revision(
       returns ``status='collision'`` without mutating anything, so the caller
       can confirm before overwriting.
     - If the posting is already tracked as its own stray series (matched by
-      fingerprint under a different change_id), that series is absorbed
-      (``absorbed=True``) rather than duplicated.
+      message-id, then fingerprint, under a different change_id), that series
+      is absorbed (``absorbed=True``) rather than duplicated.  A stray whose
+      series rows are all archived is left alone and the revision is simply
+      recorded.
     - Otherwise the revision and its patches are recorded with
       ``source='manual'``.
 
@@ -2283,17 +2339,20 @@ def record_linked_revision(
         return result
 
     fingerprint = lser.fingerprint
-    stray = find_revision_by_fingerprint(conn, fingerprint)
-    if stray is not None and stray['change_id'] != change_id:
-        absorb_series_as_revision(
+    stray = find_stray_revision(conn, change_id, ref_msg.msgid, fingerprint)
+    if stray is not None:
+        # Acted on, not assumed: absorb still declines a revision with
+        # neither a series nor a catalog row, and the revision the
+        # maintainer asked to link would then go unrecorded while the TUI
+        # reported a link and an absorbed duplicate.
+        result['absorbed'] = absorb_series_as_revision(
             conn,
             change_id,
             stray['change_id'],
             revision,
             stray_revision=stray.get('revision'),
         )
-        result['absorbed'] = True
-    else:
+    if not result['absorbed']:
         message_id = ref_msg.msgid
         add_revision(
             conn,
@@ -4162,6 +4221,26 @@ def update_revision_message_counts(
     }
 
 
+def _is_archived_only(conn: sqlite3.Connection, change_id: str) -> bool:
+    """Whether *change_id* has series rows and every one of them is archived.
+
+    The v11 migration gives every series row a catalog entry, archived ones
+    included, so a catalog hit under another change_id no longer proves
+    anything is still tracked there.  A change_id with no series row at all
+    is a different matter -- that is a catalogued posting, and colliding
+    with it still matters -- so only the all-archived case is exempt.
+    """
+    row = conn.execute(
+        'SELECT COUNT(*),'
+        " SUM(COALESCE(status, 'new') != 'archived') FROM series"
+        ' WHERE change_id = ?',
+        (change_id,),
+    ).fetchone()
+    if row is None or not row[0]:
+        return False
+    return not (row[1] or 0)
+
+
 def mark_all_messages_seen(
     conn: sqlite3.Connection, change_id: str, revision: int
 ) -> None:
diff --git a/src/b4/review_tui/_tracking_app.py b/src/b4/review_tui/_tracking_app.py
index bcad86f3..f0ad512a 100644
--- a/src/b4/review_tui/_tracking_app.py
+++ b/src/b4/review_tui/_tracking_app.py
@@ -4006,8 +4006,12 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
             known = {
                 r['revision'] for r in b4.review.tracking.get_revisions(conn, change_id)
             }
-            stray = b4.review.tracking.find_revision_by_fingerprint(
-                conn, lser.fingerprint
+            ref_msg = b4.review.tracking._series_ref_message(lser)
+            stray = b4.review.tracking.find_stray_revision(
+                conn,
+                change_id,
+                ref_msg.msgid if ref_msg is not None else None,
+                lser.fingerprint,
             )
             conn.close()
         except Exception as ex:
@@ -4019,7 +4023,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         warning = ''
         if collision:
             warning = f'v{revision} is already tracked — linking replaces it.'
-        elif stray is not None and stray['change_id'] != change_id:
+        elif stray is not None:
             warning = 'Already tracked as a separate series — it will be absorbed.'
 
         num_patches = sum(1 for p in lser.patches[1:] if p is not None)

-- 
2.53.0


^ permalink raw reply related	[flat|nested] 26+ messages in thread

* [PATCH RFC v2 19/25] review: add backward discovery of older series revisions
  2026-08-12 21:46 [PATCH RFC v2 00/25] review: track and browse every version of a tracked series Christian Brauner
                   ` (17 preceding siblings ...)
  2026-08-12 21:46 ` [PATCH RFC v2 18/25] review: match a stray posting by message-id Christian Brauner
@ 2026-08-12 21:47 ` Christian Brauner
  2026-08-12 21:47 ` [PATCH RFC v2 20/25] review-tui: add a "Find older revisions" action Christian Brauner
                   ` (5 subsequent siblings)
  24 siblings, 0 replies; 26+ messages in thread
From: Christian Brauner @ 2026-08-12 21:47 UTC (permalink / raw)
  To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Christian Brauner (Amutable)

Auto-discovery only looks forward, so versions posted before a series was
tracked never enter the revisions catalog unless they shared the seed
thread.

Add discover_older_revisions().  It fetches the tracked thread and runs
the same get_extra_series() machinery b4 am/mbox uses to pull other
revisions, with an explicit wantvers covering every previous version,
since the backward search otherwise fetches only latest-1.  Newly
recorded revisions are polled immediately so they arrive with counts and
cached thread blobs.

The update sweep runs the same backward search once per series, latched
in a dedicated back_searched column.  Neither the catalog nor revision
provenance can record that a search ran and found nothing: the v11
backfill gives every series row a catalog entry, and offline tracking and
manual linking both stamp a source with no search behind them.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 src/b4/review/_review.py  |  45 +++++---
 src/b4/review/tracking.py | 263 +++++++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 293 insertions(+), 15 deletions(-)

diff --git a/src/b4/review/_review.py b/src/b4/review/_review.py
index 68b5a7a0..2ed7c340 100644
--- a/src/b4/review/_review.py
+++ b/src/b4/review/_review.py
@@ -2497,22 +2497,38 @@ def update_series_tracking(
     if b4.can_network:
         try:
             _conn = b4.review.tracking.get_db(identifier)
-            _known = set(
-                r['revision']
-                for r in b4.review.tracking.get_revisions(_conn, change_id)
-            )
-            _conn.close()
+            try:
+                _rows = b4.review.tracking.get_revisions(_conn, change_id)
+                _searched = b4.review.tracking.is_back_searched(_conn, change_id)
+            finally:
+                _conn.close()
         except Exception:
-            _known = set()
+            _rows = []
+            _searched = False
+        _known = {r['revision'] for r in _rows}
 
         msgs = b4.mbox.get_extra_series(msgs, direction=1, nocache=True)
-        # Discount the tracked revision's own entry.  The v11 backfill gives
-        # every series row one, so a plain "is the catalog empty?" test is
-        # never true again and this one-shot search stopped running at all.
-        if current_rev > 1 and not (_known - {current_rev}):
+        # One-shot, latched on changes.back_searched.  The catalog alone
+        # cannot carry the latch: the v11 backfill gives every series row
+        # an entry, so "is the catalog empty?" is never true again, and
+        # discounting just the tracked revision re-ran this uncached
+        # subject+sender search on every sweep, forever, for any series
+        # whose older versions cannot be found.  Revision provenance
+        # cannot carry it either: offline tracking and manual linking
+        # both stamp a source with no search behind it, and a manual
+        # stamp can never be overwritten to record a later real search.
+        if current_rev > 1 and not _searched and not (_known - {current_rev}):
             msgs = b4.mbox.get_extra_series(
                 msgs, direction=-1, wantvers=list(range(1, current_rev)), nocache=True
             )
+            try:
+                _conn = b4.review.tracking.get_db(identifier)
+                try:
+                    b4.review.tracking.set_back_searched(_conn, change_id)
+                finally:
+                    _conn.close()
+            except Exception as ex:
+                logger.debug('Could not latch the backward search: %s', ex)
 
     lmbx = b4.LoreMailbox()
     for msg in msgs:
@@ -2562,7 +2578,13 @@ def update_series_tracking(
     try:
         conn = b4.review.tracking.get_db(identifier)
         new_revs = b4.review.tracking._record_discovered_revisions(
-            conn, change_id, lmbx, str(linkmask)
+            conn,
+            change_id,
+            lmbx,
+            str(linkmask),
+            # Offline the lmbx is just the cached thread with no search
+            # behind it; 'heuristic' keeps that provenance honest.
+            source='discovered' if b4.can_network else 'heuristic',
         )
         result['new_revisions'] = len(new_revs)
         try:
@@ -2684,7 +2706,6 @@ def update_series_tracking(
         if not save_tracking_ref(topdir, branch, cover_text, tracking):
             result['error'] = 'Error saving tracking data'
             return result
-
     elif topdir and has_review_branch:
         # The block above mirrors the catalog as part of its save, but it
         # only runs for four statuses.  Every other one still holds
diff --git a/src/b4/review/tracking.py b/src/b4/review/tracking.py
index 6662cd9e..843808c4 100644
--- a/src/b4/review/tracking.py
+++ b/src/b4/review/tracking.py
@@ -922,8 +922,19 @@ def cmd_track(cmdargs: argparse.Namespace) -> None:
         # Already tracked: fold any newly discovered revisions into the
         # existing series instead of creating a duplicate (bug 70fe607).
         new_revs = _record_discovered_revisions(
-            conn, existing_change_id, lmbx, linkmask, rethreaded_revs=rethreaded_revs
+            conn,
+            existing_change_id,
+            lmbx,
+            linkmask,
+            rethreaded_revs=rethreaded_revs,
+            # Offline the lmbx is just the fetched thread with no search
+            # behind it; 'heuristic' keeps that provenance honest.
+            source='discovered' if b4.can_network else 'heuristic',
         )
+        if b4.can_network and wanted_ver > 1:
+            # The backward search above covered the older versions; the
+            # update sweep's one-shot pass need not repeat it.
+            set_back_searched(conn, existing_change_id)
         srow = conn.execute(
             'SELECT status, revision FROM series WHERE change_id = ?',
             (existing_change_id,),
@@ -990,8 +1001,19 @@ def cmd_track(cmdargs: argparse.Namespace) -> None:
     )
     add_series_patches(conn, change_id, revision, lser)
     _record_discovered_revisions(
-        conn, change_id, lmbx, linkmask, rethreaded_revs=rethreaded_revs
+        conn,
+        change_id,
+        lmbx,
+        linkmask,
+        rethreaded_revs=rethreaded_revs,
+        # Offline the lmbx is just the fetched thread with no search
+        # behind it; 'heuristic' keeps that provenance honest.
+        source='discovered' if b4.can_network else 'heuristic',
     )
+    if b4.can_network and wanted_ver > 1:
+        # The backward search above covered the older versions; the update
+        # sweep's one-shot pass need not repeat it.
+        set_back_searched(conn, change_id)
     conn.close()
     # Mirror the catalog onto the review branch when one already exists (a
     # fresh track usually has none yet — this is then a harmless no-op).
@@ -1205,7 +1227,10 @@ def get_all_tracked_series(identifier: str) -> list[dict[str, Any]]:
 
 # Provenance ranking for a revision's `source`.  A higher rank wins: a manual
 # link must override, and never be downgraded by, automated discovery.
-_SOURCE_RANK = {'heuristic': 0, 'auto-consume': 0, 'manual': 1}
+# 'discovered' sits in between: unlike 'heuristic' (a row seeded from a series
+# row by backfill) it proves a discovery pass actually processed the series,
+# which is what the update sweep's one-shot backward search latches on.
+_SOURCE_RANK = {'heuristic': 0, 'auto-consume': 0, 'discovered': 1, 'manual': 2}
 
 
 def _source_rank(source: Optional[str]) -> int:
@@ -1588,6 +1613,25 @@ def _store_catalog_synced(
     _set_change_state(conn, change_id, catalog_synced=f'{branch_sha}:{catalog_sha}')
 
 
+def set_back_searched(conn: sqlite3.Connection, change_id: str) -> None:
+    """Latch "the backward revision search ran" for a change_id.
+
+    A dedicated stamp rather than an inference from revision provenance:
+    offline tracking and manual linking both write sources with no search
+    behind them, and a search that finds nothing writes no revision rows
+    at all.  Set once the search has run, found something or not.
+    """
+    _set_change_state(conn, change_id, back_searched=1)
+
+
+def is_back_searched(conn: sqlite3.Connection, change_id: str) -> bool:
+    """Whether the one-shot backward revision search already ran."""
+    row = conn.execute(
+        'SELECT back_searched FROM changes WHERE change_id = ?', (change_id,)
+    ).fetchone()
+    return bool(row and row[0])
+
+
 def sync_revisions_catalog_to_branch(
     topdir: Optional[str], identifier: str, change_id: str
 ) -> bool:
@@ -1994,12 +2038,33 @@ def _raw_revision_ref(
     return '', '', False
 
 
+def _revision_posted_at(lmbx: 'b4.LoreMailbox', revision: int) -> Optional[str]:
+    """When a discovered revision was posted, from its own Date: header.
+
+    The backward search records versions posted long before the series was
+    tracked, so dating them "now" would sort the oldest version after the
+    newest and report a year-old posting as found today.
+    """
+    msg = lmbx.covers.get(revision)
+    if msg is None:
+        for p in getattr(lmbx.series.get(revision), 'patches', None) or []:
+            if p is not None:
+                msg = p
+                break
+    date = getattr(msg, 'date', None)
+    if not isinstance(date, datetime.datetime):
+        return None
+    return date.astimezone(datetime.timezone.utc).isoformat()
+
+
 def _record_discovered_revisions(
     conn: sqlite3.Connection,
     change_id: str,
     lmbx: 'b4.LoreMailbox',
     linkmask: str,
     rethreaded_revs: Optional[Set[int]] = None,
+    source: str = 'discovered',
+    skip_revs: Optional[Set[int]] = None,
 ) -> List[int]:
     """Record every revision discovered in *lmbx* under *change_id*.
 
@@ -2014,12 +2079,30 @@ def _record_discovered_revisions(
     their member patch message-ids are recorded — fetching the recorded
     message-id alone would not reconstitute the series, so retrieval must read
     the stored patches instead.
+
+    *source* defaults to ``'discovered'``, the rank for versions a live
+    lore search turned up.  A caller recording from a cached thread with
+    no search behind it passes ``'heuristic'``, so a later real discovery
+    or manual link still outranks those rows.  Whether the one-shot
+    backward search has run is deliberately not inferred from here -- see
+    :func:`set_back_searched`.
+
+    Revisions in *skip_revs* are left out, for a caller that has decided
+    some of what it found is not its to record -- see
+    :func:`discover_older_revisions` and the strays it reports rather than
+    absorbs.  A parameter rather than the caller deleting entries from
+    *lmbx*: that mailbox is the caller's fetch, not a scratch buffer, and
+    a reader of it after this call should still see what came back.
     """
     if rethreaded_revs is None:
         rethreaded_revs = set()
+    if skip_revs is None:
+        skip_revs = set()
     known = {r['revision'] for r in get_revisions(conn, change_id)}
     new_revs: List[int] = []
     for v in sorted(lmbx.series.keys()):
+        if v in skip_revs:
+            continue
         v_ser = lmbx.series[v]
         is_rt = v in rethreaded_revs
         v_msgid, v_subject, from_cover = _raw_revision_ref(lmbx, v, rethreaded=is_rt)
@@ -2034,8 +2117,10 @@ def _record_discovered_revisions(
             v_subject,
             v_link,
             fingerprint=getattr(v_ser, 'fingerprint', None),
+            source=source,
             is_rethreaded=is_rt,
             subject_from_cover=from_cover,
+            found_at=_revision_posted_at(lmbx, v),
         )
         if is_rt:
             add_series_patches(conn, change_id, v, v_ser)
@@ -4241,6 +4326,178 @@ def _is_archived_only(conn: sqlite3.Connection, change_id: str) -> bool:
     return not (row[1] or 0)
 
 
+def discover_older_revisions(
+    identifier: str,
+    series: Dict[str, Any],
+    linkmask: str,
+    topdir: Optional[str] = None,
+    cancel_cb: Optional[Callable[[], bool]] = None,
+) -> Dict[str, Any]:
+    """Search lore for revisions older than the tracked one and record them.
+
+    Auto-discovery only looks forward, so versions posted before a series
+    was tracked never enter the catalog unless they happened to share the
+    seed thread.  This fetches the tracked revision's thread, runs the
+    backward lore search (change-id query when the cover carries one,
+    subject+sender otherwise, capped roughly a year back), records every
+    previously unknown revision, and immediately polls the series so the
+    new revisions get counts and cached thread blobs.
+
+    A rethreaded series is reassembled from its member patches before the
+    search runs.  Its recorded message-id is one patch's, so seeding from
+    that patch's thread alone hands :func:`b4.mbox.get_extra_series` a
+    mailbox with no cover in it -- and a patch numbered above 1 is skipped
+    outright there, leaving no base message and no query issued at all.
+
+    The search returns whole revisions, not per-patch threads, so an older
+    version that was posted with broken threading is recorded as a plain
+    revision: viewing it shows the thread of the message-id that was
+    matched rather than the reassembled series.  Rethreading a version
+    still has to go through a manual link.
+
+    A version that is already tracked as its own stray series is reported
+    rather than recorded -- see the ``conflicts`` key below.
+
+    Returns ``{'found': n, 'revisions': [..], 'conflicts': [..],
+    'error': str-or-None}`` where *found* counts genuinely new catalog
+    entries, *revisions* lists their version numbers, and *conflicts*
+    lists versions skipped because another series already tracks them.
+    """
+
+    def nothing(error: Optional[str] = None) -> Dict[str, Any]:
+        return {'found': 0, 'revisions': [], 'conflicts': [], 'error': error}
+
+    change_id = series.get('change_id', '')
+    message_id = series.get('message_id', '')
+    if not change_id or not message_id:
+        return nothing('no message-id for this series')
+    tracked_rev = int(series.get('revision') or 1)
+    if tracked_rev <= 1:
+        return nothing()
+    if not b4.can_network:
+        return nothing('offline')
+
+    # Same seed the per-revision poller uses, so a rethreaded series arrives
+    # reassembled from its member patches rather than as one patch's thread.
+    # Falls back to the plain fetch whenever the patch list cannot supply a
+    # series, which is what a non-rethreaded revision always does.
+    seed_conn = get_db(identifier)
+    try:
+        msgs = _fetch_revision_thread_msgs(
+            identifier,
+            seed_conn,
+            change_id,
+            {
+                'revision': tracked_rev,
+                'message_id': message_id,
+                'is_rethreaded': bool(series.get('is_rethreaded')),
+            },
+        )
+    finally:
+        seed_conn.close()
+    if not msgs:
+        return nothing(f'could not fetch thread for {message_id}')
+    # Same machinery b4 am/mbox uses to pull other revisions of a series,
+    # except we ask for every previous version at once — without an
+    # explicit wantvers the backward search only fetches latest-1.
+    wantvers = list(range(1, tracked_rev))
+    try:
+        msgs = b4.mbox.get_extra_series(
+            msgs, direction=-1, wantvers=wantvers, nocache=True
+        )
+    except liblore.OperationCancelledError:
+        raise
+    except Exception as ex:
+        return nothing(str(ex))
+
+    lmbx = b4.LoreMailbox()
+    for msg in msgs:
+        lmbx.add_message(msg)
+    if not lmbx.series:
+        return nothing()
+
+    conn = get_db(identifier)
+    try:
+        # The backward search just ran; the update sweep's one-shot pass
+        # need not repeat it.
+        set_back_searched(conn, change_id)
+        # Absorbing a stray removes that revision from the series that owns
+        # it, which a search action should not do unprompted: skip and
+        # report, [l] absorbs.
+        #
+        # Only for versions this change_id does not already have.  A series
+        # whose catalog already covers what the search turned up has nothing
+        # to link: [l] would absorb a posting it already holds, and since
+        # add_revision() is first-wins on message_id it would not even change
+        # the row -- it would only delete the series that owned the other
+        # copy.  A change_id synthesized per posting (the author sends no
+        # change-id trailer, so date+slug+fingerprint differ every version)
+        # puts every one of its versions in that state at once, which is how
+        # this came to report four conflicts on a series that had already
+        # catalogued all of them.
+        own = {r['revision'] for r in get_revisions(conn, change_id)}
+        conflicts = set()
+        for v in sorted(lmbx.series.keys()):
+            if v in own:
+                continue
+            if v == tracked_rev:
+                # The seed thread is the tracked revision's, so lmbx always
+                # holds it, and a stray duplicate of it matches here like any
+                # other.  Reporting it would send the maintainer to [l] to
+                # link the version they are already on.  Same reason
+                # new_revs below drops it.
+                #
+                # Only that one revision, not everything from it up: a newer
+                # version can ride along in the seed thread (an author
+                # posting vN+1 as a reply), and skipping the check for it
+                # would record a posting another series already owns without
+                # anyone being told.
+                continue
+            v_msgid, _, _ = _raw_revision_ref(lmbx, v)
+            fingerprint = getattr(lmbx.series[v], 'fingerprint', None)
+            if find_stray_revision(conn, change_id, v_msgid, fingerprint) is not None:
+                conflicts.add(v)
+        recorded = _record_discovered_revisions(
+            conn, change_id, lmbx, linkmask, skip_revs=conflicts
+        )
+    finally:
+        conn.close()
+
+    # The seed thread is the tracked revision's, so lmbx always holds it
+    # too, and it counts as new whenever the catalog had no row for it.
+    # Recording that row is worth having; reporting the version the
+    # maintainer is already on as an older one found is not.
+    new_revs = [v for v in recorded if v < tracked_rev]
+
+    if recorded:
+        # Mirror the new entries into the review branch's tracking commit,
+        # as every other _record_discovered_revisions caller does, so they
+        # travel with the branch on push.
+        if topdir:
+            sync_revisions_catalog_to_branch(topdir, identifier, change_id)
+    # Named outright so other revisions awaiting a first fetch cannot spend
+    # the budget on these -- but still inside it, or one [o] press on a v20
+    # series answers with 19 round-trips.
+    if new_revs:
+        try:
+            update_revision_message_counts(
+                identifier,
+                [series],
+                topdir=topdir,
+                only_revisions=set(new_revs),
+                max_revisions_per_series=REVISION_POLL_LIMIT,
+                cancel_cb=cancel_cb,
+            )
+        except liblore.OperationCancelledError:
+            pass
+    return {
+        'conflicts': sorted(conflicts),
+        'found': len(new_revs),
+        'revisions': sorted(new_revs),
+        'error': None,
+    }
+
+
 def mark_all_messages_seen(
     conn: sqlite3.Connection, change_id: str, revision: int
 ) -> None:

-- 
2.53.0


^ permalink raw reply related	[flat|nested] 26+ messages in thread

* [PATCH RFC v2 20/25] review-tui: add a "Find older revisions" action
  2026-08-12 21:46 [PATCH RFC v2 00/25] review: track and browse every version of a tracked series Christian Brauner
                   ` (18 preceding siblings ...)
  2026-08-12 21:47 ` [PATCH RFC v2 19/25] review: add backward discovery of older series revisions Christian Brauner
@ 2026-08-12 21:47 ` Christian Brauner
  2026-08-12 21:47 ` [PATCH RFC v2 21/25] review: test the catalog mirror, stray matching and backward discovery Christian Brauner
                   ` (4 subsequent siblings)
  24 siblings, 0 replies; 26+ messages in thread
From: Christian Brauner @ 2026-08-12 21:47 UTC (permalink / raw)
  To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Christian Brauner (Amutable)

Offer discover_older_revisions() from the tracker's action menu, wherever
manual revision linking is offered.  The search runs in a lore worker; on
success the list reloads, deferring to the DB mtime poll when a modal is
up.

The documentation also gains the "Link a revision" line the partial block
never listed, although that action has been offered for partial series
all along.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 docs/maintainer/review.rst         |  9 ++++
 src/b4/review_tui/_tracking_app.py | 96 ++++++++++++++++++++++++++++++++++++++
 2 files changed, 105 insertions(+)

diff --git a/docs/maintainer/review.rst b/docs/maintainer/review.rst
index ca063269..5ddd9dd2 100644
--- a/docs/maintainer/review.rst
+++ b/docs/maintainer/review.rst
@@ -252,6 +252,8 @@ actions depend on the series status:
 * ``[s]`` **Snooze** — defer until a date, duration, or git tag
 * ``[U]`` **Upgrade** — switch to a newer revision (when available)
 * ``[l]`` **Link a revision** — manually associate a revision by message-id
+* ``[o]`` **Find older revisions** — search lore for versions posted before
+  the series was tracked
 * ``[A]`` **Abandon** / ``[x]`` **Archive**
 
 **Partial** (some patches applied, remainder still in review):
@@ -262,6 +264,9 @@ actions depend on the series status:
 * ``[w]`` **Mark as waiting** — waiting on a new revision
 * ``[s]`` **Snooze** — defer until later
 * ``[U]`` **Upgrade** — switch to a newer revision (when available)
+* ``[l]`` **Link a revision** — manually associate a revision by message-id
+* ``[o]`` **Find older revisions** — search lore for versions posted before
+  the series was tracked
 * ``[A]`` **Abandon** / ``[x]`` **Archive**
 
 **New / gone:**
@@ -270,6 +275,8 @@ actions depend on the series status:
 * ``[U]`` **Upgrade** — switch to a newer revision (new only, when available)
 * ``[s]`` **Snooze** — defer until later (new only)
 * ``[l]`` **Link a revision** — manually associate a revision by message-id (new only)
+* ``[o]`` **Find older revisions** — search lore for versions posted before
+  the series was tracked (new only)
 * ``[A]`` **Abandon**
 
 **Waiting:**
@@ -277,6 +284,8 @@ actions depend on the series status:
 * ``[U]`` **Upgrade** — switch to the newer revision (when available)
 * ``[r]`` **Review** — return to reviewing
 * ``[l]`` **Link a revision** — manually associate a revision by message-id
+* ``[o]`` **Find older revisions** — search lore for versions posted before
+  the series was tracked
 * ``[A]`` **Abandon** / ``[x]`` **Archive**
 
 **Snoozed:**
diff --git a/src/b4/review_tui/_tracking_app.py b/src/b4/review_tui/_tracking_app.py
index f0ad512a..999513ca 100644
--- a/src/b4/review_tui/_tracking_app.py
+++ b/src/b4/review_tui/_tracking_app.py
@@ -32,6 +32,7 @@ from typing import (
     Union,
 )
 
+from rich.markup import escape
 from rich.text import Text as RichText
 from textual.app import App, ComposeResult
 from textual.binding import Binding
@@ -45,6 +46,7 @@ import b4.mbox
 import b4.review
 import b4.review.tracking
 import b4.ty
+import liblore
 from b4.review._review import NO_COVER_NOTE
 from b4.review_tui._common import (
     QUIT_BINDINGS,
@@ -65,6 +67,7 @@ from b4.review_tui._common import (
     resolve_styles,
     run_lore_worker,
     suspend_and_edit,
+    worker_cancelled,
 )
 from b4.review_tui._modals import (
     QUEUE_BUSY,
@@ -105,6 +108,7 @@ _ACTION_SHORTCUTS: Dict[str, str] = {
     'unsnooze': 'u',
     'upgrade': 'U',
     'link': 'l',
+    'discover': 'o',
     'thank': 't',
     'abandon': 'A',
     'archive': 'x',
@@ -514,6 +518,32 @@ def _resolve_worktree_take_conflict(
     return True
 
 
+def _discovery_error_notice(error: str) -> str:
+    """Message for a discovery run that came back with an error.
+
+    *error* is a lore exception's text, so it is escaped rather than
+    trusted: notify() renders Rich markup, and an unescaped bracket in it
+    is either swallowed as a style tag -- taking the diagnostic the message
+    exists to carry -- or, when the text holds a '[/...]' path fragment,
+    raises MarkupError inside the toast render.  See _conflicts_notice.
+    """
+    return f'Older-revision search failed: {escape(error)}'
+
+
+def _conflicts_notice(conflicts: List[int]) -> str:
+    """Message for versions a discovery run found another series tracking.
+
+    The ``[l]`` is escaped because notify() renders Rich markup: unescaped
+    it is parsed as a style tag, and the one key the message exists to name
+    is dropped from what the maintainer actually reads.
+    """
+    clist = ', '.join(f'v{r}' for r in conflicts)
+    return (
+        f'{clist} already tracked as a separate series'
+        f' — use {escape("[l]")} to link and absorb'
+    )
+
+
 def _format_snooze_until(value: str) -> str:
     """Format a snoozed_until value for display.
 
@@ -1089,6 +1119,44 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
             elif event.state == WorkerState.ERROR:
                 self.notify('Could not fetch series', severity='error')
             return
+        if event.worker.name == '_discover_older':
+            if event.state == WorkerState.SUCCESS:
+                result = event.worker.result or {}
+                error = result.get('error')
+                found = result.get('found', 0)
+                conflicts = result.get('conflicts') or []
+                if error == 'offline':
+                    # Not a failure, just nothing to search with.
+                    self.notify('Offline — cannot search for older revisions')
+                elif error:
+                    self.notify(_discovery_error_notice(str(error)), severity='error')
+                elif found:
+                    # found is len(revisions), so the list is never empty here.
+                    rlist = ', '.join(f'v{r}' for r in result.get('revisions') or [])
+                    self.notify(f'Found and added: {rlist}')
+                    # Reload so the new revisions show up right away; if a
+                    # modal is up, the DB mtime poll picks it up instead.
+                    if len(self.app.screen_stack) == 1:
+                        if self._selected_series:
+                            self._focus_change_id = self._selected_series.get(
+                                'change_id'
+                            )
+                        self._invalidate_caches()
+                        self._load_series()
+                elif not conflicts:
+                    # Only when there is nothing else to say.  A run that
+                    # found versions and skipped every one of them as a
+                    # conflict reports 0 found, and saying "none found"
+                    # ahead of the list of them contradicts itself.
+                    self.notify('No older revisions found')
+                if conflicts:
+                    self.notify(_conflicts_notice(conflicts), severity='warning')
+            elif event.state == WorkerState.ERROR:
+                if isinstance(event.worker.error, liblore.OperationCancelledError):
+                    # Shutting down or navigating away, not a failure.
+                    return
+                self.notify('Older-revision search failed', severity='error')
+            return
         if event.worker.name != '_startup_rescan':
             return
         if event.state == WorkerState.SUCCESS:
@@ -1516,6 +1584,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
                 actions.append(('upgrade', 'Upgrade to newer revision'))
             if status == 'new':
                 actions.append(('link', 'Manually link a revision'))
+                actions.append(('discover', 'Find older revisions'))
             actions.append(('abandon', 'Abandon series'))
             if status == 'new':
                 actions.append(('waiting', 'Mark as waiting on new revision'))
@@ -1548,6 +1617,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
                 actions.append(('thank', 'Send thank-you'))
             if status in ('reviewing', 'replied', 'partial', 'waiting'):
                 actions.append(('link', 'Manually link a revision'))
+                actions.append(('discover', 'Find older revisions'))
             # 'Return to reviewing' sits just above the abandon/archive block
             # rather than at the top of the menu.
             if status in ('accepted', 'partial', 'thanked'):
@@ -1572,6 +1642,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
             'thank': self.action_thank,
             'upgrade': self.action_update_revision,
             'link': self.action_link_revision,
+            'discover': self.action_discover_older,
             'archive': self.action_archive,
             'waiting': self.action_waiting,
             'snooze': self.action_snooze,
@@ -3957,6 +4028,31 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
             ),
         )
 
+    def action_discover_older(self) -> None:
+        """Search lore for older revisions of the selected series."""
+        if not self._selected_series or not self._identifier:
+            return
+        series = dict(self._selected_series)
+        config = b4.get_main_config()
+        linkmask = str(config.get('linkmask', ''))
+        topdir = b4.git_get_toplevel()
+        identifier = self._identifier
+        self.notify('Searching lore for older revisions…')
+
+        def _discover() -> Dict[str, Any]:
+            # The search machinery logs to the console; keep it from
+            # scribbling over the TUI.
+            with _quiet_worker():
+                return b4.review.tracking.discover_older_revisions(
+                    identifier,
+                    series,
+                    linkmask,
+                    topdir=topdir,
+                    cancel_cb=worker_cancelled,
+                )
+
+        run_lore_worker(self, _discover, name='_discover_older')
+
     def action_link_revision(self) -> None:
         """Manually link another revision to the selected series by msgid.
 

-- 
2.53.0


^ permalink raw reply related	[flat|nested] 26+ messages in thread

* [PATCH RFC v2 21/25] review: test the catalog mirror, stray matching and backward discovery
  2026-08-12 21:46 [PATCH RFC v2 00/25] review: track and browse every version of a tracked series Christian Brauner
                   ` (19 preceding siblings ...)
  2026-08-12 21:47 ` [PATCH RFC v2 20/25] review-tui: add a "Find older revisions" action Christian Brauner
@ 2026-08-12 21:47 ` Christian Brauner
  2026-08-12 21:47 ` [PATCH RFC v2 22/25] review-tui: extract the Msgs column renderer from TrackedSeriesItem Christian Brauner
                   ` (3 subsequent siblings)
  24 siblings, 0 replies; 26+ messages in thread
From: Christian Brauner @ 2026-08-12 21:47 UTC (permalink / raw)
  To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Christian Brauner (Amutable)

Cover the catalog-sync watermark answering "already current" without
touching git, and a catalog write breaking it.  Cover stray matching by
message-id, a fingerprint that drifted, a catalog-only stray, and an
all-archived owner left alone.  Cover discover_older_revisions(): every
previous version recorded with an explicit wantvers, revisions dated from
their own posting, the one-shot latch, offline, the v1 no-op, conflicts
reported rather than absorbed, and the budget the follow-up poll runs
inside.

Also the branch sha recorded once per change_id, the force flag bypassing
the minimum-age skip, and the catalog thread snapshot advancing while the
tracking ref is frozen.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 src/tests/test_review.py          |  133 +++
 src/tests/test_review_tracking.py | 1612 +++++++++++++++++++++++++++++++++++++
 src/tests/test_tui_tracking.py    |   47 ++
 3 files changed, 1792 insertions(+)

diff --git a/src/tests/test_review.py b/src/tests/test_review.py
index f3adcb5a..a40b9af6 100644
--- a/src/tests/test_review.py
+++ b/src/tests/test_review.py
@@ -5544,3 +5544,136 @@ class TestExplicitUpdateReachesASnoozedSeries:
         the cron sweep's lore budget.
         """
         assert self._run('snooze-sweep', monkeypatch, forced=False) == []
+
+
+class TestCatalogMirrorSkipsABranchlessSeries:
+    """The sweep enumerates the review branches once; asking again is waste.
+
+    Every status outside BRANCH_UPDATE_STATUSES falls through to the
+    mirror below the branch save, and a series that has never been checked
+    out has nothing to mirror onto -- but the sync only learns that by
+    opening the database and spending a rev-parse, once per series, every
+    sweep.  A tracking list is mostly 'new' series.
+    """
+
+    @staticmethod
+    def _run(
+        monkeypatch: pytest.MonkeyPatch, identifier: str, **kwargs: Any
+    ) -> List[str]:
+        change_id = f'{identifier}-cid'
+        conn = b4.review.tracking.init_db(identifier)
+        b4.review.tracking.add_series_to_db(
+            conn,
+            change_id,
+            1,
+            'Subject',
+            'Author',
+            'a@example.com',
+            '2024-01-15T10:00:00+00:00',
+            'cover@example.com',
+            2,
+        )
+        conn.close()
+        synced: List[str] = []
+        monkeypatch.setattr(
+            b4.review.tracking,
+            'sync_revisions_catalog_to_branch',
+            lambda topdir, ident, cid: synced.append(cid),
+        )
+        monkeypatch.setattr(b4, 'git_worktree_busy', lambda topdir, branch: False)
+        monkeypatch.setattr(
+            b4.review.tracking, 'store_revision_thread_blob', lambda *a, **kw: None
+        )
+        monkeypatch.setattr(
+            b4.review.tracking, '_store_thread_blob', lambda *a, **kw: None
+        )
+
+        msg = email.message.EmailMessage()
+        msg['Message-Id'] = f'<{change_id}-r1@example.com>'
+        msg['Date'] = 'Mon, 27 Jul 2026 10:00:00 +0000'
+        mock_lmbx = mock.Mock()
+        mock_lmbx.series = {}
+        mock_lmbx.covers = {}
+        mock_lmbx.get_series.return_value = None
+        series_dict: Dict[str, Any] = {
+            'change_id': change_id,
+            'revision': 1,
+            'status': 'new',
+            'message_id': 'cover@example.com',
+        }
+        with (
+            mock.patch(
+                'b4.review._review.retrieve_series_messages', return_value=[msg]
+            ),
+            mock.patch('b4.LoreMailbox', return_value=mock_lmbx),
+        ):
+            review.update_series_tracking(
+                series_dict,
+                identifier,
+                'https://example.com/%s',
+                topdir='/nonexistent',
+                **kwargs,
+            )
+        return synced
+
+    def test_a_branchless_series_is_not_mirrored(
+        self, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        assert self._run(monkeypatch, 'mirror-none', review_branches=set()) == []
+
+    def test_a_series_with_a_branch_still_is(
+        self, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        assert self._run(
+            monkeypatch,
+            'mirror-some',
+            review_branches={'b4/review/mirror-some-cid'},
+        ) == ['mirror-some-cid']
+
+    def test_not_knowing_falls_back_to_asking(
+        self, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """A rescan that could not run must not mute the mirror."""
+        assert self._run(monkeypatch, 'mirror-unknown') == ['mirror-unknown-cid']
+
+
+def test_update_all_tracking_forwards_the_branch_set(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    """One enumeration at the top of the sweep, not one git call per series."""
+    seen: List[Any] = []
+    monkeypatch.setattr(
+        b4.review.tracking,
+        'rescan_branches',
+        lambda identifier, topdir: {
+            'gone': 0,
+            'changed': 0,
+            'branches': {'b4/review/a'},
+        },
+    )
+    monkeypatch.setattr(
+        b4.review.tracking,
+        'get_all_tracked_series',
+        lambda identifier: [
+            {'change_id': 'a', 'subject': 's', 'status': 'new', 'sender_name': 'A'}
+        ],
+    )
+
+    def _fake(
+        one: Dict[str, Any],
+        identifier: str,
+        linkmask: str,
+        topdir: Optional[str] = None,
+        **kw: Any,
+    ) -> Dict[str, Any]:
+        seen.append(kw.get('review_branches'))
+        return {'new_revisions': 0, 'new_trailers': 0, 'error': None}
+
+    monkeypatch.setattr(review, 'update_series_tracking', _fake)
+    monkeypatch.setattr(
+        b4.review.tracking, 'update_revision_message_counts', lambda *a, **kw: {}
+    )
+    review.update_all_tracking(
+        'fwd-branches', 'https://lore.example/r/%s', topdir='/nonexistent'
+    )
+    assert seen == [{'b4/review/a'}]
diff --git a/src/tests/test_review_tracking.py b/src/tests/test_review_tracking.py
index 145fac8f..532c65c9 100644
--- a/src/tests/test_review_tracking.py
+++ b/src/tests/test_review_tracking.py
@@ -15,6 +15,7 @@ import pytest
 pytest.importorskip('textual')
 
 import b4
+import b4.mbox
 import b4.review
 import liblore
 from b4.review import tracking as review_tracking
@@ -1589,6 +1590,61 @@ class TestRescanBranches:
         assert row['status'] == 'replied'
         conn.close()
 
+    def test_rescan_records_one_branch_sha_per_change_id(self, gitdir: str) -> None:
+        """A change_id's branch sha is one fact, however many rows it has.
+
+        rescan_branches and the catalog-sync fast path both read it, and
+        while it lived on `series` they had to agree on which of a
+        change_id's rows to believe -- a stale copy on a higher-revision
+        row vouched for a watermark the branch move had just invalidated,
+        and an external branch reset then never re-mirrored the catalog.
+        On `changes` there is nothing to pick between.
+        """
+        identifier = 'rescan-onesha'
+        conn = review_tracking.init_db(identifier)
+        # A leftover row at a higher revision than the branch tracks,
+        # carrying the pre-move sha.
+        review_tracking.add_series_to_db(
+            conn,
+            'multi-change',
+            revision=3,
+            subject='Test series v3',
+            sender_name='Test Author',
+            sender_email='author@example.com',
+            sent_at='2024-01-15T10:00:00+00:00',
+            message_id='multi-change-v3@example.com',
+            num_patches=3,
+        )
+        review_tracking.set_branch_sha(conn, 'multi-change', 'stale-sha')
+        conn.close()
+
+        tracking_data = self._make_tracking_data('multi-change', identifier=identifier)
+        branch = _create_review_branch(gitdir, 'multi-change', tracking_data)
+        ecode, sha_out = b4.git_run_command(gitdir, ['rev-parse', branch])
+        assert ecode == 0
+        current_sha = sha_out.strip()
+
+        review_tracking.rescan_branches(identifier, gitdir, branch=branch)
+
+        conn = review_tracking.get_db(identifier)
+        revisions = [
+            r['revision']
+            for r in conn.execute(
+                "SELECT revision FROM series WHERE change_id = 'multi-change'"
+                ' ORDER BY revision'
+            )
+        ]
+        stored = review_tracking.get_branch_sha(conn, 'multi-change')
+        rows = conn.execute(
+            "SELECT COUNT(*) FROM changes WHERE change_id = 'multi-change'"
+        ).fetchone()[0]
+        conn.close()
+        # The branch's tracking commit names v1, and the leftover v3 row
+        # is untouched by the rescan -- yet there is still exactly one sha.
+        assert revisions == [1, 3]
+        assert rows == 1
+        assert stored == current_sha
+
 
 class TestFollowupCounts:
     """Tests for message_count / seen_message_count tracking."""
@@ -3697,6 +3753,109 @@ class TestRecordLinkedRevision:
         assert review_tracking.get_revisions(conn, 'series-B') == []
         conn.close()
 
+    def test_link_absorbs_a_stray_whose_fingerprint_drifted(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """The message-id is what discover_older_revisions matched on.
+
+        A fingerprint hashes only the patches present, so a stray recorded
+        from a partial fetch does not match the one computed here -- and the
+        conflict notice sends the maintainer to [l] precisely then.  Matching
+        on fingerprint alone recorded a second copy of the same posting
+        instead of absorbing it.
+        """
+        conn = review_tracking.init_db('mrl-link-absorb-msgid')
+        _seed_target(conn, 'series-A', 1)
+        lser = _build_series(
+            '[PATCH v2] foo: fix bar', _AUTHOR, 2, msgid='<shared-v2@example.com>'
+        )
+        _seed_stray_series(conn, 'series-B', 2, 'a-stale-fingerprint')
+        conn.execute(
+            "UPDATE revisions SET message_id = 'shared-v2@example.com'"
+            " WHERE change_id = 'series-B'"
+        )
+        conn.commit()
+
+        result = review_tracking.record_linked_revision(conn, 'series-A', lser)
+
+        assert result['absorbed'] is True
+        assert review_tracking.get_revisions(conn, 'series-B') == []
+        conn.close()
+
+    def test_link_leaves_an_all_archived_stray_alone(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """Absorbing deletes a series wholesale, records and all.
+
+        The v11 migration gave archived series rows catalog entries, which
+        put them in reach of the stray match for the first time.  They are
+        invisible in the tracking list, so absorbing one destroys a series
+        the maintainer never saw and could not have been asked about --
+        which is why discover_older_revisions will not even report them.
+        """
+        conn = review_tracking.init_db('mrl-link-archived-stray')
+        _seed_target(conn, 'series-A', 1)
+        lser = _build_series('[PATCH v2] foo: fix bar', _AUTHOR, 2)
+        _seed_stray_series(conn, 'series-B', 2, lser.fingerprint)
+        conn.execute(
+            "UPDATE series SET status = 'archived' WHERE change_id = 'series-B'"
+        )
+        conn.commit()
+
+        result = review_tracking.record_linked_revision(conn, 'series-A', lser)
+
+        assert result['status'] == 'linked'
+        assert result['absorbed'] is False
+        # The revision is recorded on the target...
+        revs = review_tracking.get_revisions(conn, 'series-A')
+        assert [r['revision'] for r in revs] == [1, 2]
+        # ...and the archived series still exists.
+        assert (
+            conn.execute(
+                "SELECT COUNT(*) FROM series WHERE change_id = 'series-B'"
+            ).fetchone()[0]
+            == 1
+        )
+        conn.close()
+
+    def test_link_absorbs_a_catalog_only_stray(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """A catalog row with no series row behind it is still absorbable.
+
+        An upgraded stray keeps the version it left behind exactly that
+        way, and refusing it there records the same message-id under two
+        change_ids -- which then resolves by table order.
+        """
+        conn = review_tracking.init_db('mrl-link-catalog-only-stray')
+        _seed_target(conn, 'series-A', 1)
+        lser = _build_series(
+            '[PATCH v2] foo: fix bar', _AUTHOR, 2, msgid='<catalog-only@test.com>'
+        )
+        # A catalogued posting with no series row of its own.
+        review_tracking.add_revision(
+            conn,
+            'series-B',
+            2,
+            'catalog-only@test.com',
+            subject='[PATCH v2] foo: fix bar',
+            fingerprint=lser.fingerprint,
+        )
+
+        result = review_tracking.record_linked_revision(conn, 'series-A', lser)
+
+        revs = review_tracking.get_revisions(conn, 'series-A')
+        owners = conn.execute(
+            'SELECT change_id FROM revisions WHERE message_id = ?',
+            ('catalog-only@test.com',),
+        ).fetchall()
+        conn.close()
+        assert result['status'] == 'linked'
+        assert result['absorbed'] is True
+        assert [r['revision'] for r in revs] == [1, 2]
+        # One owner, so find_revision_by_message_id has nothing to pick between.
+        assert [r[0] for r in owners] == ['series-A']
+
 
 class TestUnlinkRevision:
     """Tier 5: unlink_revision() undoes a manual link only."""
@@ -4625,6 +4784,138 @@ class TestSyncRevisionsCatalogToBranch:
         _cover, tracking = b4.review.load_tracking(gitdir, 'b4/review/cid-A')
         assert tracking.get('known-revisions', []) == []
 
+    def test_steady_state_answers_without_git(
+        self, gitdir: str, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """The every-sweep no-op must not cost subprocesses.
+
+        The update sweep lands here each pass for every series outside the
+        four statuses whose branch save mirrors the catalog itself --
+        accepted and thanked included -- so "already current" has to come
+        from the catalog_synced watermark, not from three git reads.
+
+        The watermark is only half the answer: it names the branch sha the
+        mirror was made against, and only rescan_branches can say the
+        database has actually imported that sha.  So the sweep order is the
+        precondition -- rescan first, then this -- and the fast path stays
+        shut until it holds.
+        """
+        identifier = 'rt-port-sync-fast'
+        _make_review_branch_with_catalog(gitdir, identifier, 'cid-A', 5, [])
+        conn = review_tracking.init_db(identifier)
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid-A',
+            revision=5,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='v5@example.com',
+            num_patches=1,
+        )
+        conn.close()
+        assert (
+            review_tracking.sync_revisions_catalog_to_branch(
+                gitdir, identifier, 'cid-A'
+            )
+            is True
+        )
+        # As update_all_tracking does at the top of every sweep.
+        review_tracking.rescan_branches(identifier, gitdir)
+        monkeypatch.setattr(
+            b4,
+            'git_run_command',
+            lambda *a, **kw: pytest.fail('steady-state sync must not touch git'),
+        )
+        monkeypatch.setattr(
+            b4,
+            'git_worktree_busy',
+            lambda *a, **kw: pytest.fail('steady-state sync must not touch git'),
+        )
+        assert (
+            review_tracking.sync_revisions_catalog_to_branch(
+                gitdir, identifier, 'cid-A'
+            )
+            is False
+        )
+
+    def test_a_catalog_write_breaks_the_watermark(self, gitdir: str) -> None:
+        identifier = 'rt-port-sync-dirty'
+        _make_review_branch_with_catalog(gitdir, identifier, 'cid-A', 5, [])
+        conn = review_tracking.init_db(identifier)
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid-A',
+            revision=5,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='v5@example.com',
+            num_patches=1,
+        )
+        conn.close()
+        assert review_tracking.sync_revisions_catalog_to_branch(
+            gitdir, identifier, 'cid-A'
+        )
+        conn = review_tracking.get_db(identifier)
+        review_tracking.add_revision(conn, 'cid-A', 6, 'v6@example.com')
+        conn.close()
+        assert review_tracking.sync_revisions_catalog_to_branch(
+            gitdir, identifier, 'cid-A'
+        )
+        _cover, tracking = b4.review.load_tracking(gitdir, 'b4/review/cid-A')
+        known = {e['revision'] for e in tracking.get('known-revisions', [])}
+        assert known == {5, 6}
+
+    def test_migration_adds_the_watermark_column(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        _make_legacy_v10_db('sync-mig-watermark')
+        conn = review_tracking.get_db('sync-mig-watermark')
+        cols = {row[1] for row in conn.execute('PRAGMA table_info(changes)')}
+        version = conn.execute('SELECT version FROM schema_version').fetchone()[0]
+        conn.close()
+        assert 'catalog_synced' in cols
+        assert version == review_tracking.SCHEMA_VERSION
+
+    def test_the_mirror_does_not_vouch_for_an_unimported_branch(
+        self, gitdir: str
+    ) -> None:
+        """branch_sha means "imported", and only rescan_branches can say it.
+
+        The mirror reads a branch and may move it; it never imports its
+        tracking commit.  Recording the sha it saw as though it had made
+        rescan_branches skip that branch for ever -- so a status and a
+        known-revisions block pushed from another machine were silently
+        never replayed.
+        """
+        identifier = 'sync-no-vouch'
+        _make_review_branch_with_catalog(gitdir, identifier, 'cid-A', 5, [])
+        conn = review_tracking.init_db(identifier)
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid-A',
+            revision=5,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='v5@example.com',
+            num_patches=1,
+        )
+        conn.close()
+        # The mirror runs against a branch the database has never imported.
+        review_tracking.sync_revisions_catalog_to_branch(gitdir, identifier, 'cid-A')
+        conn = review_tracking.get_db(identifier)
+        vouched = review_tracking.get_branch_sha(conn, 'cid-A')
+        conn.close()
+        assert vouched is None
+        # ...so the rescan still reads it.
+        result = review_tracking.rescan_branches(identifier, gitdir)
+        assert result['changed'] == 1
+
 
 class TestKnownProjects:
     """Tests for the identifier→repository reverse mapping."""
@@ -4768,6 +5059,40 @@ class TestUpdateMessageCountSeenBump:
         assert self._get_counts(conn) == (3, 3)
         conn.close()
 
+    def test_the_catalog_snapshot_advances_on_every_counted_fetch(
+        self, monkeypatch: pytest.MonkeyPatch, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """The catalog copy is written whatever the tracking commit does.
+
+        seen_bump is derived from the previous thread snapshot, so a
+        snapshot that stops advancing while a branch is checked out
+        re-counts the same read messages as new on every sweep -- the
+        badge is then silently cleared for mail never opened.  The
+        catalog row's copy must therefore keep advancing regardless.
+        """
+        conn = self._setup_series('bump-frozen')
+        ref_saves: list[int] = []
+        cat_saves: list[int] = []
+        monkeypatch.setattr(
+            review_tracking,
+            '_store_thread_blob',
+            lambda topdir, cid, msgs, blob_sha=None: ref_saves.append(len(msgs)),
+        )
+        monkeypatch.setattr(
+            review_tracking,
+            'store_revision_thread_blob',
+            lambda conn, topdir, change_id, revision, msgs: cat_saves.append(len(msgs)),
+        )
+        review_tracking.update_message_count_from_msgs(
+            conn, 'bump-cid', 1, self._make_msgs(3), topdir='/nonexistent'
+        )
+        assert (cat_saves, ref_saves) == ([3], [3])
+        review_tracking.update_message_count_from_msgs(
+            conn, 'bump-cid', 1, self._make_msgs(5), topdir='/nonexistent'
+        )
+        assert (cat_saves, ref_saves) == ([3, 5], [3, 5])
+        conn.close()
+
 
 class TestFindTrackedChangeId:
     """Tests for find_tracked_change_id()."""
@@ -6392,6 +6717,44 @@ class TestUpdateRevisionMessageCounts:
         assert mbox is not None
         assert b'm-0@example.com' in mbox
 
+    def test_force_bypasses_the_minimum_age_skip(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """An explicit refresh polls versions the schedule would skip.
+
+        The poller's minimum-age gate exists for unattended sweeps; on a
+        user-initiated update a recently checked version being skipped
+        silently reads as the update not working.
+        """
+        now = datetime.datetime.now(datetime.timezone.utc).isoformat()
+        conn = review_tracking.init_db('poll-force')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 5, seen_message_count = 5,'
+            ' last_update_check = ?, last_mail_at = ?'
+            " WHERE change_id = 'cid' AND revision = 1",
+            (now, now),
+        )
+        conn.commit()
+        conn.close()
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: _thread_msgs(7),
+        )
+        quiet = review_tracking.update_revision_message_counts(
+            'poll-force', [_poller_series('cid', 2, 'v2@x')]
+        )
+        assert quiet['polled'] == 0
+        forced = review_tracking.update_revision_message_counts(
+            'poll-force', [_poller_series('cid', 2, 'v2@x')], force=True
+        )
+        assert forced['polled'] == 1
+        conn = review_tracking.get_db('poll-force')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[1]['message_count'] == 7
+
 
 class TestRevisionAwareSyncHelpers:
     """refresh_message_count / sync_seen fall back to the catalog."""
@@ -8457,3 +8820,1252 @@ class TestMergeTrackedRevisionsHelper:
         # No read state: the entry supplies a message-id, not a badge.
         assert synth['message_count'] is None
         assert synth['seen_message_count'] is None
+
+
+class TestDiscoveredRevisionsAreDatedFromTheirPosting:
+    """found_at is the version's own Date:, not the moment it was recorded.
+
+    The backward search records versions posted long before the series was
+    tracked, and the version rows fall back to found_at whenever the
+    follow-up poll could not fill last_activity_at -- so dating them "now"
+    reported a year-old posting as found today and sorted the oldest
+    version after the newest.
+    """
+
+    def test_cover_date_is_used(self, tmp_path: pytest.TempPathFactory) -> None:
+        conn = review_tracking.init_db('rt-found-cover')
+        lmbx = _build_lmbx('thing', _AUTHOR, 2, 2, cover=True)
+        review_tracking._record_discovered_revisions(conn, 'cid-D', lmbx, '')
+        revs = review_tracking.get_revisions(conn, 'cid-D')
+        conn.close()
+        # 08:51:10 +0530 == 03:21:10 UTC
+        assert revs[0]['found_at'] == '2026-03-19T03:21:10+00:00'
+
+    def test_first_patch_date_is_used_without_a_cover(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        conn = review_tracking.init_db('rt-found-patch')
+        lmbx = _build_lmbx('thing', _AUTHOR, 2, 2)
+        review_tracking._record_discovered_revisions(conn, 'cid-E', lmbx, '')
+        revs = review_tracking.get_revisions(conn, 'cid-E')
+        conn.close()
+        assert revs[0]['found_at'] == '2026-03-19T03:21:12+00:00'
+
+
+class TestDiscoverOlderRevisions:
+    """The backward lore search records all previous versions."""
+
+    def test_records_all_previous_versions(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        conn = review_tracking.init_db('disc-all')
+        review_tracking.add_revision(conn, 'cid', 3, 'thing-v3-p1@example.com')
+        conn.close()
+        monkeypatch.setattr(b4, 'can_network', True)
+        base_msgs = _series_msgs('thing', _AUTHOR, 3, 2)
+        monkeypatch.setattr(
+            review_tracking, '_fetch_thread_msgs', lambda msgid: list(base_msgs)
+        )
+        seen_calls: list[tuple[int, Any]] = []
+
+        def _fake_extra(
+            msgs: list[EmailMessage],
+            direction: int = 1,
+            wantvers: Any = None,
+            nocache: bool = False,
+        ) -> list[EmailMessage]:
+            seen_calls.append((direction, wantvers))
+            return (
+                list(msgs)
+                + _series_msgs('thing', _AUTHOR, 2, 2)
+                + _series_msgs('thing', _AUTHOR, 1, 2)
+            )
+
+        monkeypatch.setattr(b4.mbox, 'get_extra_series', _fake_extra)
+        polled: list[tuple[str, Any]] = []
+        monkeypatch.setattr(
+            review_tracking,
+            'update_revision_message_counts',
+            lambda identifier, series_list, topdir=None, max_revisions_per_series=None, cancel_cb=None, only_revisions=None: (
+                polled.append((series_list[0]['change_id'], only_revisions))
+            ),
+        )
+        series = {
+            'change_id': 'cid',
+            'revision': 3,
+            'message_id': 'thing-v3-p1@example.com',
+            'status': 'new',
+        }
+        result = review_tracking.discover_older_revisions('disc-all', series, '')
+        assert result == {
+            'found': 2,
+            'revisions': [1, 2],
+            'conflicts': [],
+            'error': None,
+        }
+        assert seen_calls == [(-1, [1, 2])]
+        # Named outright: the two revisions just recorded, not a budget
+        # any other uncounted revision could spend.
+        assert polled == [('cid', {1, 2})]
+        conn = review_tracking.get_db('disc-all')
+        revs = {r['revision'] for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs == {1, 2, 3}
+
+    def test_rethreaded_series_is_reassembled_before_searching(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """A rethreaded seed is one patch's thread, which finds nothing.
+
+        get_extra_series() skips any patch numbered above 1 and gives up
+        without issuing a query when that leaves no base message, so
+        seeding from the recorded message-id alone can search for nothing
+        at all.  Reassemble from the member patches first, exactly as the
+        per-revision poller does.
+        """
+        conn = review_tracking.init_db('disc-rethreaded')
+        # The recorded message-id is patch 2's: the seed thread for it holds
+        # no cover and no 1/N, which is what defeats the backward search.
+        review_tracking.add_revision(
+            conn, 'cid', 3, 'thing-v3-p2@example.com', is_rethreaded=True
+        )
+        _insert_patches(
+            conn,
+            'cid',
+            3,
+            ['thing-v3-p1@example.com', 'thing-v3-p2@example.com'],
+        )
+        conn.close()
+        monkeypatch.setattr(b4, 'can_network', True)
+
+        def _no_plain_fetch(msgid: str) -> list[EmailMessage]:
+            raise AssertionError('must reassemble, not fetch one patch thread')
+
+        monkeypatch.setattr(review_tracking, '_fetch_thread_msgs', _no_plain_fetch)
+        seen_series: list[dict[str, Any]] = []
+
+        def _reassemble(series: dict[str, Any], identifier: str) -> list[EmailMessage]:
+            seen_series.append(series)
+            return _series_msgs('thing', _AUTHOR, 3, 2)
+
+        monkeypatch.setattr(b4.review, 'retrieve_series_messages', _reassemble)
+        seen_calls: list[tuple[int, Any]] = []
+
+        def _fake_extra(
+            msgs: list[EmailMessage],
+            direction: int = 1,
+            wantvers: Any = None,
+            nocache: bool = False,
+        ) -> list[EmailMessage]:
+            seen_calls.append((direction, wantvers))
+            return list(msgs) + _series_msgs('thing', _AUTHOR, 2, 2)
+
+        monkeypatch.setattr(b4.mbox, 'get_extra_series', _fake_extra)
+        monkeypatch.setattr(
+            review_tracking,
+            'update_revision_message_counts',
+            lambda *a, **kw: {'updated': 0, 'new_mail': 0, 'errors': 0, 'polled': 0},
+        )
+        series = {
+            'change_id': 'cid',
+            'revision': 3,
+            'message_id': 'thing-v3-p2@example.com',
+            'is_rethreaded': True,
+            'status': 'new',
+        }
+        result = review_tracking.discover_older_revisions('disc-rethreaded', series, '')
+
+        assert seen_series and seen_series[0]['is_rethreaded'] is True
+        assert seen_calls == [(-1, [1, 2])]
+        assert result['found'] == 1
+        assert result['revisions'] == [2]
+
+    def test_tracked_revision_is_not_reported_as_found(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """The seed thread is v3's, so v3 is always in the mailbox.
+
+        With no catalog row for it -- the case the whole tracked-revision
+        synthesis exists for -- it counted as newly discovered, and the
+        search reported the version the maintainer is already on.
+        """
+        review_tracking.init_db('disc-tracked').close()
+        monkeypatch.setattr(b4, 'can_network', True)
+        base_msgs = _series_msgs('thing', _AUTHOR, 3, 2)
+        monkeypatch.setattr(
+            review_tracking, '_fetch_thread_msgs', lambda msgid: list(base_msgs)
+        )
+        monkeypatch.setattr(
+            b4.mbox,
+            'get_extra_series',
+            lambda msgs, direction=1, wantvers=None, nocache=False: (
+                list(msgs) + _series_msgs('thing', _AUTHOR, 2, 2)
+            ),
+        )
+        monkeypatch.setattr(
+            review_tracking,
+            'update_revision_message_counts',
+            lambda *a, **kw: None,
+        )
+        series = {
+            'change_id': 'cid',
+            'revision': 3,
+            'message_id': 'thing-v3-p1@example.com',
+            'status': 'new',
+        }
+        result = review_tracking.discover_older_revisions('disc-tracked', series, '')
+        assert result == {
+            'found': 1,
+            'revisions': [2],
+            'conflicts': [],
+            'error': None,
+        }
+        # v3 is still recorded, just not announced as a find.
+        conn = review_tracking.get_db('disc-tracked')
+        revs = {r['revision'] for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs == {2, 3}
+
+    def test_offline_reports_error(self, tmp_path: pytest.TempPathFactory) -> None:
+        review_tracking.init_db('disc-off').close()
+        series = {'change_id': 'cid', 'revision': 3, 'message_id': 'v3@x'}
+        result = review_tracking.discover_older_revisions('disc-off', series, '')
+        assert result == {
+            'found': 0,
+            'revisions': [],
+            'conflicts': [],
+            'error': 'offline',
+        }
+
+    def test_v1_is_noop(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        review_tracking.init_db('disc-v1').close()
+
+        def _boom(msgid: str) -> list[EmailMessage]:
+            raise AssertionError('must not fetch for v1')
+
+        monkeypatch.setattr(review_tracking, '_fetch_thread_msgs', _boom)
+        series = {'change_id': 'cid', 'revision': 1, 'message_id': 'v1@x'}
+        result = review_tracking.discover_older_revisions('disc-v1', series, '')
+        assert result == {
+            'found': 0,
+            'revisions': [],
+            'conflicts': [],
+            'error': None,
+        }
+
+    def test_nothing_new_found(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        conn = review_tracking.init_db('disc-none')
+        review_tracking.add_revision(conn, 'cid', 3, 'thing-v3-p1@example.com')
+        conn.close()
+        monkeypatch.setattr(b4, 'can_network', True)
+        base_msgs = _series_msgs('thing', _AUTHOR, 3, 2)
+        monkeypatch.setattr(
+            review_tracking, '_fetch_thread_msgs', lambda msgid: list(base_msgs)
+        )
+        monkeypatch.setattr(
+            b4.mbox,
+            'get_extra_series',
+            lambda msgs, direction=1, wantvers=None, nocache=False: list(msgs),
+        )
+
+        def _no_poll(*args: Any, **kw: Any) -> None:
+            raise AssertionError('poller must not run when nothing was found')
+
+        monkeypatch.setattr(review_tracking, 'update_revision_message_counts', _no_poll)
+        series = {
+            'change_id': 'cid',
+            'revision': 3,
+            'message_id': 'thing-v3-p1@example.com',
+        }
+        result = review_tracking.discover_older_revisions('disc-none', series, '')
+        assert result == {
+            'found': 0,
+            'revisions': [],
+            'conflicts': [],
+            'error': None,
+        }
+
+
+class TestBackwardSearchOneShot:
+    """The sweep's backward search runs once per series, then latches.
+
+    The catalog alone cannot carry the latch -- the v11 backfill gives
+    every series row an entry -- and revision provenance cannot either:
+    offline tracking and manual linking both stamp a source with no
+    search behind it.  A dedicated series.back_searched column records
+    that the search ran, found older versions or not.
+    """
+
+    @staticmethod
+    def _sweep(
+        identifier: str,
+        monkeypatch: pytest.MonkeyPatch,
+        backward: list[list[int]],
+        online: bool = True,
+    ) -> None:
+        monkeypatch.setattr(b4, 'can_network', online)
+
+        def _fake_extra(
+            msgs: list[EmailMessage],
+            direction: int = 1,
+            wantvers: Any = None,
+            nocache: bool = False,
+        ) -> list[EmailMessage]:
+            if direction == -1:
+                backward.append(list(wantvers or []))
+            return list(msgs)
+
+        monkeypatch.setattr(b4.mbox, 'get_extra_series', _fake_extra)
+        monkeypatch.setattr(
+            'b4.review._review.retrieve_series_messages',
+            lambda series, identifier: _series_msgs('thing', _AUTHOR, 3, 1),
+        )
+        series = {
+            'change_id': 'os-cid',
+            'revision': 3,
+            'status': 'new',
+            'message_id': 'thing-v3-p1@example.com',
+        }
+        result = b4.review.update_series_tracking(
+            series, identifier, 'https://l.example/%s'
+        )
+        assert result.get('error') is None
+
+    @staticmethod
+    def _seed(identifier: str) -> None:
+        conn = review_tracking.init_db(identifier)
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='os-cid',
+            revision=3,
+            subject='thing',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='thing-v3-p1@example.com',
+            num_patches=1,
+        )
+        conn.close()
+
+    def test_finding_nothing_still_latches(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """A v3 whose v1/v2 are not on lore must not be re-searched forever.
+
+        The subject+sender query behind the backward search is the
+        expensive kind, and 'found nothing' records no catalog row -- so a
+        guard reading only the revision set re-fired it on every 'u', 'U'
+        and cron pass for the lifetime of the series.
+        """
+        self._seed('oneshot-dry')
+        backward: list[list[int]] = []
+        self._sweep('oneshot-dry', monkeypatch, backward)
+        assert backward == [[1, 2]]
+        conn = review_tracking.get_db('oneshot-dry')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'os-cid')}
+        assert review_tracking.is_back_searched(conn, 'os-cid')
+        conn.close()
+        assert revs[3]['source'] == 'discovered'
+        self._sweep('oneshot-dry', monkeypatch, backward)
+        assert backward == [[1, 2]]
+
+    def test_an_offline_sweep_does_not_latch(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Recording a cached thread proves nothing about lore."""
+        self._seed('oneshot-offline')
+        backward: list[list[int]] = []
+        self._sweep('oneshot-offline', monkeypatch, backward, online=False)
+        assert backward == []
+        self._sweep('oneshot-offline', monkeypatch, backward)
+        assert backward == [[1, 2]]
+
+    def test_a_manual_link_does_not_latch(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Relinking the tracked revision proves nothing about lore.
+
+        [l] and absorb promote the tracked revision's row to 'manual'
+        through add_revision's rank upgrade.  The old provenance latch
+        read any rank above 'heuristic' as "search already ran", so a
+        relinked series never had its older versions looked for.
+        """
+        self._seed('oneshot-manual')
+        conn = review_tracking.get_db('oneshot-manual')
+        review_tracking.add_revision(
+            conn, 'os-cid', 3, 'thing-v3-p1@example.com', source='manual'
+        )
+        conn.close()
+        backward: list[list[int]] = []
+        self._sweep('oneshot-manual', monkeypatch, backward)
+        assert backward == [[1, 2]]
+
+
+class TestDiscoverOlderConflicts:
+    def test_stray_tracked_version_is_reported_not_duplicated(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """v1 tracked under its own change_id must not become a second row."""
+        conn = review_tracking.init_db('disc-conflict')
+        monkeypatch.setattr(b4, 'can_network', True)
+        base_msgs = _series_msgs('thing', _AUTHOR, 3, 2)
+        older = _series_msgs('thing', _AUTHOR, 1, 2)
+        # Record v1 under a different change_id, by its own fingerprint.
+        lmbx = b4.LoreMailbox()
+        for msg in older:
+            lmbx.add_message(msg)
+        review_tracking.add_revision(
+            conn,
+            'stray-cid',
+            1,
+            'thing-v1-p1@example.com',
+            fingerprint=lmbx.series[1].fingerprint,
+        )
+        review_tracking.add_revision(conn, 'cid', 3, 'thing-v3-p1@example.com')
+        conn.commit()
+        conn.close()
+        monkeypatch.setattr(
+            review_tracking, '_fetch_thread_msgs', lambda msgid: list(base_msgs)
+        )
+        monkeypatch.setattr(
+            b4.mbox,
+            'get_extra_series',
+            lambda msgs, direction=1, wantvers=None, nocache=False: list(msgs) + older,
+        )
+        monkeypatch.setattr(
+            review_tracking,
+            'update_revision_message_counts',
+            lambda *a, **kw: {'updated': 0, 'new_mail': 0, 'errors': 0, 'polled': 0},
+        )
+        series = {
+            'change_id': 'cid',
+            'revision': 3,
+            'message_id': 'thing-v3-p1@example.com',
+        }
+        result = review_tracking.discover_older_revisions('disc-conflict', series, '')
+        assert result['conflicts'] == [1]
+        assert result['found'] == 0
+        conn = review_tracking.get_db('disc-conflict')
+        revs = {r['revision'] for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs == {3}
+
+    def test_a_version_we_already_catalog_is_not_a_conflict(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Nothing to link, so nothing to report.
+
+        A series without a change-id trailer gets one synthesized per
+        posting, so tracking three versions of it makes three change_ids
+        that each catalog the same history.  Every version then matches a
+        stray, and the report sent the maintainer to [l] for versions the
+        series already held -- where absorbing would not change the row
+        (add_revision is first-wins on message_id) and would only delete
+        the series that owned the other copy.
+        """
+        conn = review_tracking.init_db('disc-conflict-own')
+        monkeypatch.setattr(b4, 'can_network', True)
+        base_msgs = _series_msgs('thing', _AUTHOR, 3, 2)
+        older = _series_msgs('thing', _AUTHOR, 1, 2)
+        lmbx = b4.LoreMailbox()
+        for msg in older:
+            lmbx.add_message(msg)
+        # A live series elsewhere owns v1 ...
+        review_tracking.add_revision(
+            conn,
+            'stray-cid',
+            1,
+            'thing-v1-p1@example.com',
+            fingerprint=lmbx.series[1].fingerprint,
+        )
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='stray-cid',
+            revision=1,
+            subject='[PATCH] thing',
+            sender_name='S',
+            sender_email='s@e.com',
+            sent_at='2026-01-01T00:00:00+00:00',
+            message_id='thing-v1-p1@example.com',
+            num_patches=2,
+        )
+        # ... and so do we, already.
+        review_tracking.add_revision(conn, 'cid', 1, 'thing-v1-p1@example.com')
+        review_tracking.add_revision(conn, 'cid', 3, 'thing-v3-p1@example.com')
+        conn.commit()
+        conn.close()
+        monkeypatch.setattr(
+            review_tracking, '_fetch_thread_msgs', lambda msgid: list(base_msgs)
+        )
+        monkeypatch.setattr(
+            b4.mbox,
+            'get_extra_series',
+            lambda msgs, direction=1, wantvers=None, nocache=False: list(msgs) + older,
+        )
+        series = {
+            'change_id': 'cid',
+            'revision': 3,
+            'message_id': 'thing-v3-p1@example.com',
+        }
+        result = review_tracking.discover_older_revisions(
+            'disc-conflict-own', series, ''
+        )
+        assert result['conflicts'] == []
+        assert result['found'] == 0
+        # and the stray series is still there, untouched
+        conn = review_tracking.get_db('disc-conflict-own')
+        assert (
+            conn.execute(
+                "SELECT COUNT(*) FROM series WHERE change_id = 'stray-cid'"
+            ).fetchone()[0]
+            == 1
+        )
+        conn.close()
+
+    def test_partially_retrieved_stray_is_still_caught(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """A fingerprint hashes only the patches present.
+
+        The backward search routinely returns an older version short a
+        patch, so its recomputed fingerprint need not match the one stored
+        when that version was tracked standalone.  The message-id does not
+        move, and catching the stray by that is what keeps one posting from
+        landing under two change_ids.
+        """
+        conn = review_tracking.init_db('disc-conflict-partial')
+        monkeypatch.setattr(b4, 'can_network', True)
+        base_msgs = _series_msgs('thing', _AUTHOR, 3, 2)
+        older = _series_msgs('thing', _AUTHOR, 1, 2)
+        # The stray was recorded from the whole v1; the search below returns
+        # it one patch short, so the fingerprints cannot agree.
+        lmbx = b4.LoreMailbox()
+        for msg in older:
+            lmbx.add_message(msg)
+        full_fingerprint = lmbx.series[1].fingerprint
+        review_tracking.add_revision(
+            conn,
+            'stray-cid',
+            1,
+            'thing-v1-p1@example.com',
+            fingerprint=full_fingerprint,
+        )
+        review_tracking.add_revision(conn, 'cid', 3, 'thing-v3-p1@example.com')
+        conn.commit()
+        conn.close()
+        partial = [m for m in older if 'p2' not in str(m['Message-Id'])]
+        plmbx = b4.LoreMailbox()
+        for msg in partial:
+            plmbx.add_message(msg)
+        assert plmbx.series[1].fingerprint != full_fingerprint
+
+        monkeypatch.setattr(
+            review_tracking, '_fetch_thread_msgs', lambda msgid: list(base_msgs)
+        )
+        monkeypatch.setattr(
+            b4.mbox,
+            'get_extra_series',
+            lambda msgs, direction=1, wantvers=None, nocache=False: (
+                list(msgs) + partial
+            ),
+        )
+        monkeypatch.setattr(
+            review_tracking,
+            'update_revision_message_counts',
+            lambda *a, **kw: {'updated': 0, 'new_mail': 0, 'errors': 0, 'polled': 0},
+        )
+        series = {
+            'change_id': 'cid',
+            'revision': 3,
+            'message_id': 'thing-v3-p1@example.com',
+        }
+        result = review_tracking.discover_older_revisions(
+            'disc-conflict-partial', series, ''
+        )
+        assert result['conflicts'] == [1]
+        assert result['found'] == 0
+        conn = review_tracking.get_db('disc-conflict-partial')
+        rows = conn.execute(
+            "SELECT change_id FROM revisions WHERE message_id = 'thing-v1-p1@example.com'"
+        ).fetchall()
+        conn.close()
+        # One posting, one catalog row.
+        assert [r[0] for r in rows] == ['stray-cid']
+
+    def test_the_tracked_revision_is_never_a_conflict(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """The seed thread is the tracked revision's, so lmbx always holds it.
+
+        A stray duplicate of that same posting matches the stray lookup like
+        any other, and reporting it sends the maintainer to [l] to link the
+        version they are already sitting on.  The search is for *older*
+        revisions; its report is about those.
+        """
+        conn = review_tracking.init_db('disc-self-conflict')
+        monkeypatch.setattr(b4, 'can_network', True)
+        v2 = _series_msgs('thing', _AUTHOR, 2, 2)
+        # another change_id already owns the very posting 'cid' tracks
+        review_tracking.add_revision(conn, 'dup-cid', 2, 'thing-v2-p1@example.com')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='dup-cid',
+            revision=2,
+            subject='[PATCH v2] thing',
+            sender_name='S',
+            sender_email='s@e.com',
+            sent_at='2026-01-01T00:00:00+00:00',
+            message_id='thing-v2-p1@example.com',
+            num_patches=2,
+        )
+        review_tracking.add_revision(conn, 'cid', 2, 'thing-v2-p1@example.com')
+        conn.commit()
+        conn.close()
+        monkeypatch.setattr(
+            review_tracking, '_fetch_thread_msgs', lambda msgid: list(v2)
+        )
+        monkeypatch.setattr(
+            b4.mbox,
+            'get_extra_series',
+            lambda msgs, direction=1, wantvers=None, nocache=False: list(msgs),
+        )
+        series = {
+            'change_id': 'cid',
+            'revision': 2,
+            'message_id': 'thing-v2-p1@example.com',
+        }
+        result = review_tracking.discover_older_revisions(
+            'disc-self-conflict', series, ''
+        )
+        assert result['conflicts'] == []
+        assert result['found'] == 0
+
+
+class TestDiscoverIgnoresArchivedStrays:
+    def test_an_archived_only_stray_is_not_a_conflict(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """The v11 migration gave archived series rows catalog entries too.
+
+        Reporting one sends the maintainer after a series the tracking list
+        does not show and [l] cannot reach.
+        """
+        conn = review_tracking.init_db('disc-archived')
+        monkeypatch.setattr(b4, 'can_network', True)
+        base_msgs = _series_msgs('thing', _AUTHOR, 3, 2)
+        older = _series_msgs('thing', _AUTHOR, 1, 2)
+        lmbx = b4.LoreMailbox()
+        for msg in older:
+            lmbx.add_message(msg)
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='stray-cid',
+            revision=1,
+            subject='[PATCH] thing',
+            sender_name='S',
+            sender_email='s@e.com',
+            sent_at='2026-01-01T00:00:00+00:00',
+            message_id='thing-v1-p1@example.com',
+            num_patches=2,
+        )
+        review_tracking.add_revision(
+            conn,
+            'stray-cid',
+            1,
+            'thing-v1-p1@example.com',
+            fingerprint=lmbx.series[1].fingerprint,
+        )
+        review_tracking.update_series_status(conn, 'stray-cid', 'archived', revision=1)
+        review_tracking.add_revision(conn, 'cid', 3, 'thing-v3-p1@example.com')
+        conn.commit()
+        conn.close()
+        monkeypatch.setattr(
+            review_tracking, '_fetch_thread_msgs', lambda msgid: list(base_msgs)
+        )
+        monkeypatch.setattr(
+            b4.mbox,
+            'get_extra_series',
+            lambda msgs, direction=1, wantvers=None, nocache=False: list(msgs) + older,
+        )
+        monkeypatch.setattr(
+            review_tracking,
+            'update_revision_message_counts',
+            lambda *a, **kw: {'updated': 0, 'new_mail': 0, 'errors': 0, 'polled': 0},
+        )
+        series = {
+            'change_id': 'cid',
+            'revision': 3,
+            'message_id': 'thing-v3-p1@example.com',
+        }
+        result = review_tracking.discover_older_revisions('disc-archived', series, '')
+        assert result['conflicts'] == []
+        assert result['found'] == 1
+        conn = review_tracking.get_db('disc-archived')
+        revs = {r['revision'] for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs == {1, 3}
+
+
+class TestAbsorbIsRevisionScoped:
+    """Absorbing one version must not delete a stray's other postings."""
+
+    @staticmethod
+    def _seed(identifier: str) -> sqlite3.Connection:
+        conn = review_tracking.init_db(identifier)
+        _seed_target(conn, 'target', 3)
+        for rev in (1, 2, 3):
+            review_tracking.add_series_to_db(
+                conn,
+                change_id='stray',
+                revision=rev,
+                subject=f'[PATCH v{rev}] s',
+                sender_name='n',
+                sender_email='e@x',
+                sent_at='2026-06-01T00:00:00+00:00',
+                message_id=f'stray-v{rev}@x',
+                num_patches=1,
+            )
+            review_tracking.add_revision(
+                conn, 'stray', rev, f'stray-v{rev}@x', source='manual'
+            )
+            _insert_patches(conn, 'stray', rev, [f'stray-v{rev}-p1@x'])
+        conn.commit()
+        return conn
+
+    def test_other_versions_survive(self, tmp_path: pytest.TempPathFactory) -> None:
+        """Their per-patch message-ids cannot be re-derived from the list."""
+        conn = self._seed('absorb-scope')
+        assert review_tracking.absorb_series_as_revision(
+            conn, 'target', 'stray', 2, stray_revision=2
+        )
+        left = [
+            r[0]
+            for r in conn.execute(
+                "SELECT revision FROM series WHERE change_id = 'stray'"
+                ' ORDER BY revision'
+            )
+        ]
+        patches = conn.execute(
+            "SELECT COUNT(*) FROM series_patches WHERE change_id = 'stray'"
+        ).fetchone()[0]
+        conn.close()
+        assert left == [1, 3]
+        assert patches == 2
+
+    def test_the_last_version_takes_the_leftovers(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """Nothing reaches a change_id with no series row left."""
+        conn = review_tracking.init_db('absorb-last')
+        _seed_target(conn, 'target', 2)
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='stray',
+            revision=1,
+            subject='[PATCH] s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='stray-v1@x',
+            num_patches=1,
+        )
+        review_tracking.add_revision(conn, 'stray', 1, 'stray-v1@x')
+        _insert_patches(conn, 'stray', 1, ['stray-v1-p1@x'])
+        conn.commit()
+        assert review_tracking.absorb_series_as_revision(
+            conn, 'target', 'stray', 1, stray_revision=1
+        )
+        rows = [
+            conn.execute(
+                f"SELECT COUNT(*) FROM {t} WHERE change_id = 'stray'"
+            ).fetchone()[0]
+            for t in ('series', 'revisions', 'series_patches')
+        ]
+        conn.close()
+        assert rows == [0, 0, 0]
+
+    def test_an_absorb_merges_read_state_instead_of_dropping_it(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """Totals take the larger side, seen the smaller, stamps the newer.
+
+        The stray is where the maintainer was actually reading, and its
+        rows are deleted by the absorb -- a copy gated on the target being
+        blank threw that read state away whenever the forward sweep had
+        first-fetched the same posting under the target (seen = count),
+        rendering genuinely unread mail read.  A false badge from the
+        conservative merge clears on open; a suppressed one never comes
+        back.
+        """
+        conn = self._seed('absorb-readstate')
+        # Already catalogued and first-fetched under the target, which is
+        # what the forward sweep plus the poller leave behind.
+        review_tracking.add_revision(conn, 'target', 2, 'target-v2@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 30, seen_message_count = 30,'
+            " last_update_check = '2026-01-01T00:00:00+00:00'"
+            " WHERE change_id = 'target' AND revision = 2"
+        )
+        conn.execute(
+            'UPDATE revisions SET message_count = 4, seen_message_count = 1,'
+            " last_update_check = '2026-06-01T00:00:00+00:00',"
+            " last_mail_at = '2026-05-30T00:00:00+00:00'"
+            " WHERE change_id = 'stray' AND revision = 2"
+        )
+        conn.commit()
+        review_tracking.absorb_series_as_revision(
+            conn, 'target', 'stray', 2, stray_revision=2
+        )
+        row = conn.execute(
+            'SELECT message_count, seen_message_count, last_update_check,'
+            ' last_mail_at FROM revisions'
+            " WHERE change_id = 'target' AND revision = 2"
+        ).fetchone()
+        conn.close()
+        assert (row[0], row[1]) == (30, 1)
+        assert row[2] == '2026-06-01T00:00:00+00:00'
+        assert row[3] == '2026-05-30T00:00:00+00:00'
+
+
+class TestStrayMatchingIsOneRule:
+    """[l]'s preview, the absorb it performs and [o]'s conflicts agree."""
+
+    def test_message_id_beats_a_stale_fingerprint(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """A revision recorded from a partial fetch hashes differently."""
+        conn = review_tracking.init_db('stray-msgid')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='owner',
+            revision=2,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=1,
+        )
+        review_tracking.add_revision(
+            conn, 'owner', 2, 'v2@x', fingerprint='partial-hash'
+        )
+        conn.commit()
+        found = review_tracking.find_stray_revision(
+            conn, 'other', 'v2@x', 'complete-hash'
+        )
+        conn.close()
+        assert found is not None
+        assert found['change_id'] == 'owner'
+
+    def test_an_all_archived_owner_is_not_a_match(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """It is invisible in the tracking list, so it cannot be acted on."""
+        conn = review_tracking.init_db('stray-archived')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='owner',
+            revision=2,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=1,
+        )
+        review_tracking.add_revision(conn, 'owner', 2, 'v2@x', fingerprint='fp')
+        review_tracking.update_series_status(conn, 'owner', 'archived')
+        conn.commit()
+        found = review_tracking.find_stray_revision(conn, 'other', 'v2@x', 'fp')
+        conn.close()
+        assert found is None
+
+    def test_the_owning_series_itself_is_not_a_stray(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        conn = review_tracking.init_db('stray-self')
+        review_tracking.add_revision(conn, 'mine', 2, 'v2@x', fingerprint='fp')
+        conn.commit()
+        found = review_tracking.find_stray_revision(conn, 'mine', 'v2@x', 'fp')
+        conn.close()
+        assert found is None
+
+    def test_the_target_holding_the_msgid_does_not_hide_the_stray(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """Nothing constrains a message-id to one change_id.
+
+        Auto-discovery records the posting under the link target, then the
+        maintainer links it: two catalog rows for one message-id.  Taking
+        the first row back answers with the target whenever it sorts first
+        -- 'aaa' before 'zzz' -- so [l] reported no stray and duplicated
+        the series instead of absorbing it.
+        """
+        conn = review_tracking.init_db('stray-shadowed')
+        # 'aaa' is the link target and sorts first.
+        review_tracking.add_revision(conn, 'aaa', 2, 'v2@x', fingerprint='fp')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='zzz',
+            revision=2,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=1,
+        )
+        review_tracking.add_revision(conn, 'zzz', 2, 'v2@x', fingerprint='fp')
+        conn.commit()
+        found = review_tracking.find_stray_revision(conn, 'aaa', 'v2@x', 'fp')
+        conn.close()
+        assert found is not None
+        assert found['change_id'] == 'zzz'
+
+    def test_an_archived_owner_does_not_hide_a_live_one(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """Same walk, one row further: 'aaa' is archived, 'zzz' is not."""
+        conn = review_tracking.init_db('stray-archived-first')
+        for cid in ('aaa', 'zzz'):
+            review_tracking.add_series_to_db(
+                conn,
+                change_id=cid,
+                revision=2,
+                subject='s',
+                sender_name='n',
+                sender_email='e@x',
+                sent_at='2026-06-01T00:00:00+00:00',
+                message_id='v2@x',
+                num_patches=1,
+            )
+            review_tracking.add_revision(conn, cid, 2, 'v2@x', fingerprint='fp')
+        review_tracking.update_series_status(conn, 'aaa', 'archived')
+        conn.commit()
+        found = review_tracking.find_stray_revision(conn, 'other', 'v2@x', 'fp')
+        conn.close()
+        assert found is not None
+        assert found['change_id'] == 'zzz'
+
+
+class TestMarkingReadNeverUnreads:
+    """A monotonic writer must not lower the catalog's seen count."""
+
+    def test_mark_all_seen_only_raises_the_catalog(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """The catalog can legitimately hold the larger fully-read pair."""
+        conn = review_tracking.init_db('seen-floor')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=1,
+        )
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 20, seen_message_count = 18'
+            " WHERE change_id = 'cid' AND revision = 2"
+        )
+        conn.execute(
+            'UPDATE revisions SET message_count = 25, seen_message_count = 25'
+            " WHERE change_id = 'cid' AND revision = 2"
+        )
+        conn.commit()
+        review_tracking.mark_all_messages_seen(conn, 'cid', 2)
+        row = conn.execute(
+            'SELECT message_count, seen_message_count FROM revisions'
+            " WHERE change_id = 'cid' AND revision = 2"
+        ).fetchone()
+        conn.close()
+        assert (row[0], row[1]) == (25, 25)
+
+    def test_a_drifted_catalog_is_repaired_without_a_series_move(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """The sync is the only writer that brings the copy back into line."""
+        identifier = 'seen-repair'
+        conn = review_tracking.init_db(identifier)
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=1,
+        )
+        review_tracking.add_revision(conn, 'cid', 2, 'v2@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 10, seen_message_count = 8'
+            " WHERE change_id = 'cid' AND revision = 2"
+        )
+        conn.execute(
+            'UPDATE revisions SET message_count = 10, seen_message_count = 3'
+            " WHERE change_id = 'cid' AND revision = 2"
+        )
+        conn.commit()
+        conn.close()
+        # The series row already reads 8, so it does not move; only the
+        # catalog does, and the early return used to skip it entirely.
+        assert review_tracking.sync_seen_from_unseen_count(identifier, 'cid', 2, 2)
+        conn = review_tracking.get_db(identifier)
+        row = conn.execute(
+            'SELECT seen_message_count FROM revisions'
+            " WHERE change_id = 'cid' AND revision = 2"
+        ).fetchone()
+        conn.close()
+        assert row[0] == 8
+
+
+class TestRevisionActivityIsForwardOnly:
+    """A poll must not walk a version's activity date backwards."""
+
+    def test_a_thread_that_lost_a_member_cannot_rewind_the_date(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """A rethreaded member whose thread will not fetch drops its newest
+        reply from the union while the others still raise the total."""
+        conn = review_tracking.init_db('activity-back')
+        review_tracking.add_revision(conn, 'cid', 1, 'v1@x')
+        conn.execute(
+            'UPDATE revisions SET message_count = 5, seen_message_count = 5,'
+            " last_mail_at = '2026-06-01T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+        monkeypatch.setattr(b4, 'can_network', True)
+        monkeypatch.setattr(
+            review_tracking,
+            '_fetch_revision_thread_msgs',
+            lambda identifier, conn, change_id, rev: _thread_msgs(6),
+        )
+        monkeypatch.setattr(
+            review_tracking,
+            '_latest_date_from_msgs',
+            lambda msgs: '2024-01-01T00:00:00+00:00',
+        )
+        review_tracking.update_revision_message_counts(
+            'activity-back', [_poller_series('cid', 2, 'v2@x')]
+        )
+        conn = review_tracking.get_db('activity-back')
+        row = conn.execute(
+            'SELECT message_count, last_mail_at FROM revisions'
+            " WHERE change_id = 'cid' AND revision = 1"
+        ).fetchone()
+        conn.close()
+        assert row[0] == 6
+        assert row[1] == '2026-06-01T00:00:00+00:00'
+
+
+class TestParkingDoesNotLeakMaintainerActions:
+    """last_activity_at doubles as an action stamp on `series` only."""
+
+    @staticmethod
+    def _seed(identifier: str) -> sqlite3.Connection:
+        conn = review_tracking.init_db(identifier)
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='s',
+            sender_name='n',
+            sender_email='e@x',
+            sent_at='2026-06-01T00:00:00+00:00',
+            message_id='v2@x',
+            num_patches=1,
+        )
+        conn.execute(
+            'UPDATE revisions SET message_count = 5, seen_message_count = 5'
+            " WHERE change_id = 'cid' AND revision = 2"
+        )
+        conn.commit()
+        return conn
+
+    def test_a_snooze_is_not_that_versions_newest_mail(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """The poller writes the date with its watermark, so a value newer
+        than that watermark cannot have come from a Date: header."""
+        conn = self._seed('park-snooze')
+        review_tracking.update_series_status(conn, 'cid', 'snoozed', revision=2)
+        review_tracking.update_series_revision(conn, 'cid', 2, 3, 'v3@x')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[2]['last_mail_at'] is None
+
+    def test_a_polled_date_is_still_carried(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """Refusing every series value would lose real thread activity."""
+        conn = self._seed('park-polled')
+        conn.execute(
+            "UPDATE revisions SET last_update_check = '2026-06-05T00:00:00+00:00',"
+            " last_mail_at = '2026-06-04T00:00:00+00:00'"
+            " WHERE change_id = 'cid' AND revision = 2"
+        )
+        conn.commit()
+        review_tracking.update_series_revision(conn, 'cid', 2, 3, 'v3@x')
+        revs = {r['revision']: r for r in review_tracking.get_revisions(conn, 'cid')}
+        conn.close()
+        assert revs[2]['last_mail_at'] == '2026-06-04T00:00:00+00:00'
+
+
+class TestDiscoveryRespectsThePollBudget:
+    """The poll cap applies to what a backward search just turned up too."""
+
+    @staticmethod
+    def _stub_search(monkeypatch: pytest.MonkeyPatch) -> None:
+        monkeypatch.setattr(b4, 'can_network', True)
+        base_msgs = _series_msgs('thing', _AUTHOR, 3, 2)
+        monkeypatch.setattr(
+            review_tracking, '_fetch_thread_msgs', lambda msgid: list(base_msgs)
+        )
+        monkeypatch.setattr(
+            b4.mbox,
+            'get_extra_series',
+            lambda msgs, direction=1, wantvers=None, nocache=False: (
+                list(msgs)
+                + _series_msgs('thing', _AUTHOR, 2, 2)
+                + _series_msgs('thing', _AUTHOR, 1, 2)
+            ),
+        )
+
+    def _run(
+        self, identifier: str, monkeypatch: pytest.MonkeyPatch, limit: int
+    ) -> list[dict[str, Any]]:
+        conn = review_tracking.init_db(identifier)
+        review_tracking.add_revision(conn, 'cid', 3, 'thing-v3-p1@example.com')
+        conn.close()
+        self._stub_search(monkeypatch)
+        seen: list[dict[str, Any]] = []
+
+        def _record(
+            _ident: str, _series_list: list[dict[str, Any]], **kw: Any
+        ) -> dict[str, int]:
+            seen.append(kw)
+            return {'updated': 0, 'new_mail': 0, 'errors': 0, 'polled': 0}
+
+        monkeypatch.setattr(review_tracking, 'REVISION_POLL_LIMIT', limit)
+        monkeypatch.setattr(review_tracking, 'update_revision_message_counts', _record)
+        series = {
+            'change_id': 'cid',
+            'revision': 3,
+            'message_id': 'thing-v3-p1@example.com',
+            'status': 'new',
+        }
+        review_tracking.discover_older_revisions(identifier, series, '')
+        return seen
+
+    def test_the_cap_and_a_cancel_hook_are_passed_through(
+        self, tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        """Uncapped, one [o] press on a v20 series is 19 lore round-trips."""
+        seen = self._run('disc-budget-cap', monkeypatch, 2)
+        assert len(seen) == 1
+        assert seen[0]['max_revisions_per_series'] == 2
+        assert seen[0]['only_revisions'] == {1, 2}
+
+
+class TestChangeStateIsForgotten:
+    """`changes` outlives `series` on its own -- nothing joins them.
+
+    A change_id that stops existing must not leave a row behind: its
+    back_searched latch would skip the one-shot backward search for ever
+    on a re-track, and its branch sha would make rescan_branches skip a
+    resurrected branch it had never imported.
+    """
+
+    @staticmethod
+    def _seed(identifier: str, change_id: str, revision: int = 1) -> None:
+        conn = review_tracking.init_db(identifier)
+        review_tracking.add_series_to_db(
+            conn,
+            change_id=change_id,
+            revision=revision,
+            subject='[PATCH] s',
+            sender_name='S',
+            sender_email='s@e.com',
+            sent_at='2026-01-01T00:00:00+00:00',
+            message_id=f'{change_id}-v{revision}@x',
+            num_patches=1,
+        )
+        review_tracking.set_back_searched(conn, change_id)
+        review_tracking.set_branch_sha(conn, change_id, 'deadbeef')
+        conn.close()
+
+    def _rows(self, identifier: str, change_id: str) -> int:
+        conn = review_tracking.get_db(identifier)
+        n = conn.execute(
+            'SELECT COUNT(*) FROM changes WHERE change_id = ?', (change_id,)
+        ).fetchone()[0]
+        conn.close()
+        return int(n)
+
+    def test_deleting_the_last_revision_forgets_it(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        self._seed('forget-lastrev', 'cid')
+        conn = review_tracking.get_db('forget-lastrev')
+        review_tracking.delete_series(conn, 'cid', revision=1)
+        conn.close()
+        assert self._rows('forget-lastrev', 'cid') == 0
+
+    def test_deleting_one_of_several_keeps_it(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """The other revisions still share the branch and the latch."""
+        self._seed('forget-onerev', 'cid', revision=1)
+        conn = review_tracking.get_db('forget-onerev')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='cid',
+            revision=2,
+            subject='[PATCH v2] s',
+            sender_name='S',
+            sender_email='s@e.com',
+            sent_at='2026-01-02T00:00:00+00:00',
+            message_id='cid-v2@x',
+            num_patches=1,
+        )
+        review_tracking.delete_series(conn, 'cid', revision=1)
+        conn.close()
+        assert self._rows('forget-onerev', 'cid') == 1
+        conn = review_tracking.get_db('forget-onerev')
+        assert review_tracking.is_back_searched(conn, 'cid') is True
+        conn.close()
+
+    def test_absorbing_the_last_row_forgets_the_stray(
+        self, tmp_path: pytest.TempPathFactory
+    ) -> None:
+        """Absorb deletes the stray's change_id; its state must go too."""
+        self._seed('forget-absorb', 'stray')
+        conn = review_tracking.get_db('forget-absorb')
+        review_tracking.add_series_to_db(
+            conn,
+            change_id='target',
+            revision=2,
+            subject='[PATCH v2] s',
+            sender_name='S',
+            sender_email='s@e.com',
+            sent_at='2026-01-02T00:00:00+00:00',
+            message_id='target-v2@x',
+            num_patches=1,
+        )
+        review_tracking.absorb_series_as_revision(
+            conn, 'target', 'stray', 1, stray_revision=1
+        )
+        conn.close()
+        assert self._rows('forget-absorb', 'stray') == 0
diff --git a/src/tests/test_tui_tracking.py b/src/tests/test_tui_tracking.py
index 2803bf3d..d6233afe 100644
--- a/src/tests/test_tui_tracking.py
+++ b/src/tests/test_tui_tracking.py
@@ -6030,3 +6030,50 @@ class TestUpdateAllDoesNotForceThePoll:
         assert len(seen) == 2
         assert seen[0]._force_revision_poll is True
         assert seen[1]._force_revision_poll is False
+
+
+class TestDiscoverOlderAction:
+    """The 'Find older revisions' action wires into the discovery seam."""
+
+    @pytest.mark.asyncio
+    async def test_action_menu_offers_discover(self, tmp_path: pathlib.Path) -> None:
+        _seed_db(
+            'test-discover-menu',
+            [{'change_id': 'cid-d', 'revision': 3, 'status': 'new'}],
+        )
+        app = TrackingApp('test-discover-menu')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('a')
+            await pilot.pause()
+            keys = [key for key, _label in getattr(app.screen, '_actions')]
+            assert 'discover' in keys
+
+    @pytest.mark.asyncio
+    async def test_action_runs_discovery(self, tmp_path: pathlib.Path) -> None:
+        _seed_db(
+            'test-discover-run',
+            [{'change_id': 'cid-d', 'revision': 3, 'status': 'new'}],
+        )
+        calls: List[Tuple[str, Optional[str]]] = []
+
+        def _fake_discover(
+            identifier: str,
+            series: Dict[str, Any],
+            linkmask: str,
+            topdir: Optional[str] = None,
+            cancel_cb: Optional[Callable[[], bool]] = None,
+        ) -> Dict[str, Any]:
+            calls.append((identifier, series.get('change_id')))
+            # Wired, or the run is interruptible only by tearing down the TUI.
+            assert cancel_cb is not None
+            return {'found': 2, 'revisions': [1, 2], 'error': None}
+
+        app = TrackingApp('test-discover-run')
+        with patch.object(tracking, 'discover_older_revisions', _fake_discover):
+            async with app.run_test(size=(120, 30)) as pilot:
+                await pilot.pause()
+                app.action_discover_older()
+                await app.workers.wait_for_complete()
+                await pilot.pause()
+        assert calls == [('test-discover-run', 'cid-d')]

-- 
2.53.0


^ permalink raw reply related	[flat|nested] 26+ messages in thread

* [PATCH RFC v2 22/25] review-tui: extract the Msgs column renderer from TrackedSeriesItem
  2026-08-12 21:46 [PATCH RFC v2 00/25] review: track and browse every version of a tracked series Christian Brauner
                   ` (20 preceding siblings ...)
  2026-08-12 21:47 ` [PATCH RFC v2 21/25] review: test the catalog mirror, stray matching and backward discovery Christian Brauner
@ 2026-08-12 21:47 ` Christian Brauner
  2026-08-12 21:47 ` [PATCH RFC v2 23/25] review-tui: give the unseen badge a column of its own Christian Brauner
                   ` (2 subsequent siblings)
  24 siblings, 0 replies; 26+ messages in thread
From: Christian Brauner @ 2026-08-12 21:47 UTC (permalink / raw)
  To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Christian Brauner (Amutable)

The tracker list computes the Msgs column, a thread total plus an unseen
badge, inline in TrackedSeriesItem.compose().  Per-version child rows
need the same column, so pull the computation out into _msgs_fields() and
the styled append into _append_msgs().

No functional change.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 src/b4/review_tui/_tracking_app.py | 107 +++++++++++++++++++++++--------------
 1 file changed, 66 insertions(+), 41 deletions(-)

diff --git a/src/b4/review_tui/_tracking_app.py b/src/b4/review_tui/_tracking_app.py
index 999513ca..53466498 100644
--- a/src/b4/review_tui/_tracking_app.py
+++ b/src/b4/review_tui/_tracking_app.py
@@ -726,6 +726,66 @@ def _format_attestation(att: str, app: Any = None) -> Optional[RichText]:
     return text
 
 
+def _unseen_delta(
+    message_count: Optional[int], seen_message_count: Optional[int]
+) -> int:
+    """Unseen messages in a thread, for every renderer of that number.
+
+    An absent seen count means nothing is known to have been read yet --
+    but the counts are written in pairs, so in practice that only happens
+    on rows migrated in from a database predating them.  Treat it as
+    "nothing unseen" rather than "everything unseen": two renderers
+    disagreeing about a missing value is worse than either answer.
+    """
+    if message_count is None or seen_message_count is None:
+        return 0
+    return max(0, message_count - seen_message_count)
+
+
+def _msgs_fields(
+    message_count: Optional[int], seen_message_count: Optional[int]
+) -> Tuple[str, str, bool]:
+    """Render the Msgs column for a (total, seen) message count pair.
+
+    Returns (base, badge, base_accent): "1" (all seen), "6" accented (all
+    new), "6" + "(3)" (mixed).  A never-fetched thread has no count and
+    renders as "-".  The badge is accented whenever it is non-empty.
+    """
+    if message_count is None:
+        return '-', '', False
+    if message_count == 0:
+        return '0', '', False
+    delta = _unseen_delta(message_count, seen_message_count)
+    if delta == message_count:
+        # All follow-ups are new
+        return str(message_count), '', True
+    if delta > 0:
+        # Mixed: total + (unseen)
+        return str(message_count), f'({delta})', False
+    # All seen
+    return str(message_count), '', False
+
+
+def _append_msgs(
+    label: RichText,
+    app: Any,
+    message_count: Optional[int],
+    seen_message_count: Optional[int],
+) -> None:
+    """Append the Msgs column (total + unseen badge) to *label*."""
+    base, badge, base_accent = _msgs_fields(message_count, seen_message_count)
+    base_style = ''
+    badge_style = ''
+    if base_accent or badge:
+        accent = f'bold {resolve_styles(app)["warning"]}'
+        if base_accent:
+            base_style = accent
+        if badge:
+            badge_style = accent
+    label.append(f'  {base.rjust(3)}', style=base_style)
+    label.append(f'{badge:<3s}', style=badge_style)
+
+
 class TrackedSeriesItem(ListItem):
     """A single tracked series entry in the listing."""
 
@@ -770,36 +830,6 @@ class TrackedSeriesItem(ListItem):
             art_str = f'{a}·{r}·{t}'
         else:
             art_str = '-'
-        fc = self.series.get('message_count')
-        sc = self.series.get('seen_message_count')
-        if fc is not None:
-            delta = (fc - sc) if (sc is not None and fc > sc) else 0
-        else:
-            delta = 0
-        # Msgs display: "1" (all seen), "6" accent (all new), "6(3)" mixed
-        if fc is None:
-            fu_base = '-'
-            fu_badge = ''
-            base_accent = False
-        elif fc == 0:
-            fu_base = '0'
-            fu_badge = ''
-            base_accent = False
-        elif delta == fc:
-            # All follow-ups are new
-            fu_base = str(fc)
-            fu_badge = ''
-            base_accent = True
-        elif delta > 0:
-            # Mixed: total + (unseen)
-            fu_base = str(fc)
-            fu_badge = f'({delta})'
-            base_accent = False
-        else:
-            # All seen
-            fu_base = str(fc)
-            fu_badge = ''
-            base_accent = False
         # Build compact prefix using LoreSubject to extract subsystem/modifier tokens
         ls = b4.LoreSubject(subject)
         extras = ls.get_extra_prefixes(exclude=['patch'])
@@ -822,17 +852,12 @@ class TrackedSeriesItem(ListItem):
             label.append(' ')
         label.append(' ')
         label.append(art_str.rjust(7))
-        base_style = ''
-        badge_style = ''
-        if base_accent or fu_badge:
-            ts = resolve_styles(self.app)
-            accent = f'bold {ts["warning"]}'
-            if base_accent:
-                base_style = accent
-            if fu_badge:
-                badge_style = accent
-        label.append(f'  {fu_base.rjust(3)}', style=base_style)
-        label.append(f'{fu_badge:<3s}', style=badge_style)
+        _append_msgs(
+            label,
+            self.app,
+            self.series.get('message_count'),
+            self.series.get('seen_message_count'),
+        )
         label.append(f'  {symbol}{flag}  {subject_display}')
         yield Label(label, markup=False)
 

-- 
2.53.0


^ permalink raw reply related	[flat|nested] 26+ messages in thread

* [PATCH RFC v2 23/25] review-tui: give the unseen badge a column of its own
  2026-08-12 21:46 [PATCH RFC v2 00/25] review: track and browse every version of a tracked series Christian Brauner
                   ` (21 preceding siblings ...)
  2026-08-12 21:47 ` [PATCH RFC v2 22/25] review-tui: extract the Msgs column renderer from TrackedSeriesItem Christian Brauner
@ 2026-08-12 21:47 ` Christian Brauner
  2026-08-12 21:47 ` [PATCH RFC v2 24/25] review-tui: expand tracked series into per-version rows Christian Brauner
  2026-08-12 21:47 ` [PATCH RFC v2 25/25] review-tui: test per-version tracker rows Christian Brauner
  24 siblings, 0 replies; 26+ messages in thread
From: Christian Brauner @ 2026-08-12 21:47 UTC (permalink / raw)
  To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Christian Brauner (Amutable)

The Msgs column packs the thread total and the unseen badge into adjacent
fields, so a two-digit total and a badge render as "15(3)" and read as a
single number.

Put a separator between them and widen the badge to four columns, so a
two-digit unseen count still fits, and move the header label over the
field it now describes.

Assisted-by: claude-opus-5
Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 src/b4/review_tui/_tracking_app.py | 13 ++++++++++---
 1 file changed, 10 insertions(+), 3 deletions(-)

diff --git a/src/b4/review_tui/_tracking_app.py b/src/b4/review_tui/_tracking_app.py
index 53466498..37377758 100644
--- a/src/b4/review_tui/_tracking_app.py
+++ b/src/b4/review_tui/_tracking_app.py
@@ -772,7 +772,13 @@ def _append_msgs(
     message_count: Optional[int],
     seen_message_count: Optional[int],
 ) -> None:
-    """Append the Msgs column (total + unseen badge) to *label*."""
+    """Append the Msgs column (total + unseen badge) to *label*.
+
+    Ten columns: two of lead-in, three for the total, a separator, four for
+    the badge.  The separator is what keeps a two-digit total and a badge
+    from reading as one number, and the badge is four wide so a two-digit
+    unseen count still fits inside the field.
+    """
     base, badge, base_accent = _msgs_fields(message_count, seen_message_count)
     base_style = ''
     badge_style = ''
@@ -783,7 +789,8 @@ def _append_msgs(
         if badge:
             badge_style = accent
     label.append(f'  {base.rjust(3)}', style=base_style)
-    label.append(f'{badge:<3s}', style=badge_style)
+    label.append(' ')
+    label.append(f'{badge:<4s}', style=badge_style)
 
 
 class TrackedSeriesItem(ListItem):
@@ -1432,7 +1439,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
                 await self.mount(empty, before=self.query_one(Footer))
                 return
 
-            header_text = f'{"Submitter":<20s}{"A":>1s} {"A·R·T":>7s}  {"Msgs":>6s}  {"S":<4s}{"Subject"}'
+            header_text = f'{"Submitter":<20s}{"A":>1s} {"A·R·T":>7s}  {"Msgs":<8s}{"S":<6s}{"Subject"}'
             header = Static(header_text, id='tracking-header')
 
             list_items: List[ListItem] = [TrackedSeriesItem(s) for s in display_series]

-- 
2.53.0


^ permalink raw reply related	[flat|nested] 26+ messages in thread

* [PATCH RFC v2 24/25] review-tui: expand tracked series into per-version rows
  2026-08-12 21:46 [PATCH RFC v2 00/25] review: track and browse every version of a tracked series Christian Brauner
                   ` (22 preceding siblings ...)
  2026-08-12 21:47 ` [PATCH RFC v2 23/25] review-tui: give the unseen badge a column of its own Christian Brauner
@ 2026-08-12 21:47 ` Christian Brauner
  2026-08-12 21:47 ` [PATCH RFC v2 25/25] review-tui: test per-version tracker rows Christian Brauner
  24 siblings, 0 replies; 26+ messages in thread
From: Christian Brauner @ 2026-08-12 21:47 UTC (permalink / raw)
  To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Christian Brauner (Amutable)

Series with more than one known version become expandable.  'x' unfolds
indented child rows, one per catalog revision with the tracked one
starred, showing per-version unread badges and activity dates; 'X'
toggles every series at once.  Enter or 'e' on a child opens that
revision's thread with revision-correct seen syncing, 'd' range-diffs it
against the tracked revision directly, and every other action keeps
operating on the parent series.  Expansion state and the
(change_id, revision) cursor position survive the periodic DB-mtime
reloads and limit filtering.

Focusing a series for the next rebuild goes through a helper that also
clears the stashed version.  The stash outlives a screen that closed
without rebuilding the list, so a plain _focus_change_id assignment would
drop the cursor onto a child row nobody selected, and it is that row the
thread and range-diff actions read.

has_multiple_revisions loses its last reader here.  It counts raw catalog
rows, which miss a tracked revision the catalog never recorded.  The bulk
revision-count query goes with it: the remaining caller needs the grouped
rows anyway, so counting them is a dict lookup rather than a second pass
over the table.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 docs/maintainer/review.rst         |  40 ++
 src/b4/review/tracking.py          |   8 -
 src/b4/review_tui/_modals.py       |   7 +
 src/b4/review_tui/_tracking_app.py | 743 +++++++++++++++++++++++++++++++------
 src/tests/test_review_tracking.py  |  11 -
 src/tests/test_tui_tracking.py     |  27 +-
 6 files changed, 707 insertions(+), 129 deletions(-)

diff --git a/docs/maintainer/review.rst b/docs/maintainer/review.rst
index 5ddd9dd2..8c04e7c1 100644
--- a/docs/maintainer/review.rst
+++ b/docs/maintainer/review.rst
@@ -188,12 +188,17 @@ Key           Action
 ``e``         Thread — open the lite thread viewer (see below)
 ``a``         Action menu — context-sensitive actions (see below)
 ``d``         Range-diff between revisions
+``x``         Expand or collapse the per-version rows of a series marked
+              with ``▸`` (see :ref:`version_rows`)
 ``u``         Update — fetch latest trailers and check for newer
               revisions for the selected series; press ``Escape`` or
               ``q`` to cancel
 ``U``         Update all — same as ``u`` but for all tracked series
               (skipping snoozed); press ``Escape`` or ``q`` to cancel
               mid-run — series already updated are saved
+``X``         Expand or collapse the version rows of every multi-version
+              series in the current list at once (so a ``l`` limit scopes
+              it, in both directions)
 ``l``         Limit — filter the list of displayed series. Plain text
               matches subjects and submitters; ``s:<status>`` filters
               by status, ``t:<target-branch>`` by target branch, and
@@ -328,6 +333,41 @@ the tracking database and displayed without re-checking on subsequent
 views. The attestation check honours the :term:`b4.attestation-policy`
 and :term:`b4.attestation-staleness-days` configuration options.
 
+.. _version_rows:
+
+Version rows
+~~~~~~~~~~~~
+
+A series whose catalog holds more than one known version is marked with
+a ``▸`` before its subject. Press ``x`` to expand it into one child row
+per version (``X`` expands or collapses every such series at once). An
+asterisk marks the version the series currently tracks:
+
+.. code-block:: none
+
+   ▾ [PATCH v3,00/12] introduce the frobnicator
+     ├─ v1   12 Mar    4      introduce the frobnicator
+     ├─ v2   02 Apr    9 (3)  introduce the frobnicator
+     └─ v3*  28 Apr   15      introduce the frobnicator
+
+Each row carries that version's own message count and unread badge, so
+follow-up mail arriving on an older version's thread is still visible.
+On a version row, ``Enter`` or ``e`` opens that version's thread and
+``d`` range-diffs it against the tracked revision, skipping the revision
+picker. Every other key still acts on the parent series, except the four
+actions that drive the review branch: ``r`` (review), and **Take**,
+**Rebase** and **Upgrade** in the ``a`` action menu. That branch always
+holds the revision the series tracks, so on any other version's row ``r``
+is greyed out and the three menu entries are not offered.
+
+Update sweeps poll a few non-tracked versions of each series for new
+mail, least-recently-checked first, so a series with many versions
+fills in over successive sweeps and then keeps cycling through them —
+a late reply to an old version is noticed however many versions there
+are. Every version is browsable on demand regardless.
+
+.. versionadded:: v0.17
+
 Lite thread viewer
 ~~~~~~~~~~~~~~~~~~
 Pressing ``e`` on any series opens a mutt-style thread viewer that
diff --git a/src/b4/review/tracking.py b/src/b4/review/tracking.py
index 843808c4..7f08258e 100644
--- a/src/b4/review/tracking.py
+++ b/src/b4/review/tracking.py
@@ -2551,14 +2551,6 @@ def get_all_newest_revisions(conn: sqlite3.Connection) -> dict[str, int]:
     return {row[0]: int(row[1]) for row in cursor.fetchall()}
 
 
-def get_all_revision_counts(conn: sqlite3.Connection) -> dict[str, int]:
-    """Return {change_id: revision_count} for all change_ids."""
-    cursor = conn.execute(
-        'SELECT change_id, COUNT(*) FROM revisions GROUP BY change_id'
-    )
-    return {row[0]: int(row[1]) for row in cursor.fetchall()}
-
-
 def get_all_revisions_grouped(
     conn: sqlite3.Connection,
 ) -> dict[str, list[dict[str, Any]]]:
diff --git a/src/b4/review_tui/_modals.py b/src/b4/review_tui/_modals.py
index 23d5f566..8be61319 100644
--- a/src/b4/review_tui/_modals.py
+++ b/src/b4/review_tui/_modals.py
@@ -328,9 +328,16 @@ TRACKING_HELP_LINES = [
     '  [bold]d[/bold]         Range-diff between revisions\n',
     '  [bold]a[/bold]         Open action menu (take, rebase, etc.)\n',
     '  [bold]u[/bold]         Update selected series\n',
+    '  [bold]x[/bold]         Expand/collapse the version rows of a ▸ series\n',
+    '\n',
+    '[bold]Version rows[/bold]\n',
+    '  v2*           Asterisk marks the revision the series tracks\n',
+    '  [bold]Enter[/bold] / [bold]e[/bold] View the thread of that version\n',
+    '  [bold]d[/bold]         Range-diff that version against the tracked one\n',
     '\n',
     '[bold]App[/bold]\n',
     '  [bold]U[/bold]         Update all tracked series\n',
+    '  [bold]X[/bold]         Expand/collapse all multi-version series\n',
     '  [bold]l[/bold]         Filter series by pattern\n',
     '  [bold]s[/bold]         Suspend to shell\n',
     '  [bold]p[/bold]         Switch to Patchwork TUI\n',
diff --git a/src/b4/review_tui/_tracking_app.py b/src/b4/review_tui/_tracking_app.py
index 37377758..e4a64b6e 100644
--- a/src/b4/review_tui/_tracking_app.py
+++ b/src/b4/review_tui/_tracking_app.py
@@ -28,6 +28,7 @@ from typing import (
     List,
     Literal,
     Optional,
+    Set,
     Tuple,
     Union,
 )
@@ -544,6 +545,46 @@ def _conflicts_notice(conflicts: List[int]) -> str:
     )
 
 
+def _local_stamp(stamp: Optional[str], fmt: str) -> str:
+    """Render a stored UTC timestamp in local time, or '' if unusable.
+
+    Every date the app shows is local, and these columns hold UTC, so
+    slicing the ISO string instead puts a version up to a day out from the
+    Date: header the thread viewer displays for the very same message --
+    and out of step with the version row rendering the same value.
+    """
+    if not stamp:
+        return ''
+    try:
+        return datetime.datetime.fromisoformat(stamp).astimezone().strftime(fmt)
+    except (ValueError, TypeError):
+        return ''
+
+
+def _format_version(rev: Dict[str, Any], series: Dict[str, Any]) -> str:
+    """Summarize one known version of *series* for the details panel."""
+    revision = rev.get('revision', 1)
+    out = f'v{revision}'
+    if revision == series.get('revision', 1):
+        out += ' (tracked)'
+    count = rev.get('message_count')
+    if count is None:
+        out += ' — - msgs (- unseen)'
+    else:
+        unseen = _unseen_delta(count, rev.get('seen_message_count'))
+        out += f' — {count} msgs ({unseen} unseen)'
+    # 'posted', not 'found': discovery dates a revision from its own Date:
+    # header, so the column holds when the version went out rather than when
+    # b4 noticed it (see add_revision).
+    posted = _local_stamp(rev.get('found_at'), '%Y-%m-%d')
+    if posted:
+        out += f', posted {posted}'
+    last_mail = _local_stamp(rev.get('last_mail_at'), '%Y-%m-%d')
+    if last_mail:
+        out += f', last activity {last_mail}'
+    return out
+
+
 def _format_snooze_until(value: str) -> str:
     """Format a snoozed_until value for display.
 
@@ -793,7 +834,23 @@ def _append_msgs(
     label.append(f'{badge:<4s}', style=badge_style)
 
 
-class TrackedSeriesItem(ListItem):
+class TrackingListItem(ListItem):
+    """A row of the tracking list: a series, or one version of one.
+
+    Carrying ``rev`` on both kinds is what lets every handler ask "which
+    series and which version is the cursor on?" once, instead of each one
+    re-deriving it from the row's type.
+    """
+
+    series: Dict[str, Any]
+    rev: Optional[Dict[str, Any]] = None
+
+    @property
+    def selection(self) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]:
+        return self.series, self.rev
+
+
+class TrackedSeriesItem(TrackingListItem):
     """A single tracked series entry in the listing."""
 
     DEFAULT_CSS = """
@@ -805,9 +862,18 @@ class TrackedSeriesItem(ListItem):
     }
     """
 
-    def __init__(self, series: Dict[str, Any]) -> None:
+    def __init__(
+        self,
+        series: Dict[str, Any],
+        expanded: bool = False,
+        has_versions: bool = False,
+        has_unseen_versions: bool = False,
+    ) -> None:
         super().__init__()
         self.series = series
+        self.expanded = expanded
+        self.has_versions = has_versions
+        self.has_unseen_versions = has_unseen_versions
         status = series.get('status', 'new')
         if _effective_tier(series) >= 2:
             self.add_class('non-actionable')
@@ -843,6 +909,11 @@ class TrackedSeriesItem(ListItem):
         width = len(str(num_patches)) if num_patches > 0 else 1
         parts = extras + [f'v{revision}', f'{"0" * width}/{num_patches:0{width}d}']
         subject_display = f'[{",".join(parts)}] {ls.subject}'
+        # Series with more than one known version can be expanded with [x]
+        marker = ''
+        if self.has_versions:
+            # U+25BE black down-pointing / U+25B8 black right-pointing triangle
+            marker = '▾' if self.expanded else '▸'
         if display_width(submitter) > 20:
             while display_width(submitter) > 19:
                 submitter = submitter[:-1]
@@ -865,7 +936,99 @@ class TrackedSeriesItem(ListItem):
             self.series.get('message_count'),
             self.series.get('seen_message_count'),
         )
-        label.append(f'  {symbol}{flag}  {subject_display}')
+        # The expander shares the 4-wide status field with the status
+        # symbol and the suffix flag rather than taking a column of its
+        # own: appended after it, the Subject column would sit two to the
+        # right on multi-version series only, leaving the list ragged and
+        # every row out of step with the header.
+        label.append(f'  {symbol}{flag}')
+        if marker:
+            # Accented when a version other than the tracked one has unread
+            # mail: the Msgs column above covers the tracked revision only,
+            # so without this the row is identical whether or not an older
+            # version just received a reply.
+            marker_style = ''
+            if self.has_unseen_versions:
+                marker_style = f'bold {resolve_styles(self.app)["warning"]}'
+            label.append(marker, style=marker_style)
+        else:
+            label.append(' ')
+        label.append(' ')
+        label.append(subject_display)
+        yield Label(label, markup=False)
+
+
+class TrackedRevisionItem(TrackingListItem):
+    """A single known version of an expanded series.
+
+    Rendered as a child row underneath its TrackedSeriesItem, with the
+    submitter column replaced by a tree glyph and the version number.
+    """
+
+    DEFAULT_CSS = """
+    TrackedRevisionItem Label {
+        text-style: dim;
+    }
+    """
+
+    # Narrowed from the base, which allows None for series rows: a version
+    # row always has one, and callers read it without a None check.
+    rev: Dict[str, Any]
+
+    def __init__(
+        self,
+        series: Dict[str, Any],
+        rev: Dict[str, Any],
+        is_tracked: bool,
+        is_last: bool,
+    ) -> None:
+        super().__init__()
+        self.series = series
+        # pyright anchors the narrowing above to this assignment, and rejects
+        # it because a mutable attribute's type is invariant.
+        self.rev = rev  # pyright: ignore[reportIncompatibleVariableOverride]
+        self.is_tracked = is_tracked
+        self.is_last = is_last
+
+    def compose(self) -> ComposeResult:
+        # U+2514/U+251C box drawings light up-and-right / vertical-and-right
+        tree = '└─' if self.is_last else '├─'
+        mark = '*' if self.is_tracked else ''
+        version = f'  {tree} v{self.rev.get("revision", 1)}{mark}'
+        # Date of the last known activity, falling back to when the revision
+        # was posted.  It shares the 20-wide submitter field with the version
+        # number rather than taking the parent's status columns: those carry
+        # the status symbol and the expander, and a date under a header that
+        # reads 'S' describes neither.  There is room here -- the deepest
+        # version label is nine columns of a twenty-column field.
+        date_str = _local_stamp(
+            self.rev.get('last_mail_at') or self.rev.get('found_at'), '%d %b'
+        )
+        # Show each version's own title, prefix-stripped -- the version
+        # column already carries the vN part.  Fall back to the series
+        # subject when the catalog has none.
+        rev_subject = self.rev.get('subject') or self.series.get('subject') or ''
+        if rev_subject:
+            rev_subject = b4.LoreSubject(rev_subject).subject
+        label = RichText(no_wrap=True, overflow='ellipsis')
+        # Version left, date right, both inside the submitter field.  The
+        # date is truncated rather than padded, so a locale whose abbreviated
+        # month runs long cannot push the field wide and shift the columns
+        # after it.
+        label.append(pad_display(f'{version:<10s}{date_str:.9s}', 20))
+        # Attestation (1), separator (1) and A·R·T (7) stay blank
+        label.append(' ' * 9)
+        _append_msgs(
+            label,
+            self.app,
+            self.rev.get('message_count'),
+            self.rev.get('seen_message_count'),
+        )
+        # The six the parent spends on status, expander and marker, so
+        # Subject starts at the same column on both -- and on the header.
+        label.append(' ' * 6)
+        if rev_subject:
+            label.append(rev_subject)
         yield Label(label, markup=False)
 
 
@@ -967,10 +1130,12 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         'check': 'Series',
         'thread': 'Series',
         'range_diff': 'Series',
+        'toggle_expand': 'Series',
         'action': 'Series',
         'update_one': 'Series',
         'target_branch': 'Series',
         'update_all': 'App',
+        'expand_all': 'App',
         'process_queue': 'App',
         'limit': 'App',
         'suspend': 'App',
@@ -992,7 +1157,9 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         Binding('a', 'action', 'action'),
         Binding('u', 'update_one', 'update'),
         Binding('d', 'range_diff', 'range-diff'),
+        Binding('x', 'toggle_expand', 'versions'),
         # App-global actions
+        Binding('X', 'expand_all', 'Expand all', key_display='X'),
         Binding('l', 'limit', 'limit'),
         Binding('s', 'suspend', 'shell'),
         Binding('p', 'patchwork', 'patchwork'),
@@ -1024,6 +1191,15 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         self._patatt_sign = patatt_sign
         self._all_series: List[Dict[str, Any]] = []
         self._selected_series: Optional[Dict[str, Any]] = None
+        # Rows showing per-version child rows, and the child row (if any) the
+        # cursor is on — both survive a list rebuild.  Keyed by (change_id,
+        # tracked revision), not change_id alone: rescan_branches can leave a
+        # change_id with more than one live series row and the list renders
+        # each separately, so a bare change_id expands both at once.
+        self._expanded_rows: set[Tuple[str, int]] = set()
+        self._selected_revision: Optional[Dict[str, Any]] = None
+        self._focus_series_revision: Optional[int] = None
+        self._focus_revision: Optional[int] = None
         self._limit_pattern: str = ''
         self._db_mtime: float = 0.0
         # Detect patchwork configuration
@@ -1048,7 +1224,6 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         # when u/U update runs or actions change tracking data.
         self._cached_branch_tips: Optional[Dict[str, str]] = None
         self._cached_newest_revisions: Optional[Dict[str, int]] = None
-        self._cached_revision_counts: Optional[Dict[str, int]] = None
         self._cached_revisions: Optional[Dict[str, List[Dict[str, Any]]]] = None
         # A None value is a branch whose tip carries no tracking trailer
         # block; cached as a miss so the refill below converges.
@@ -1059,22 +1234,98 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
     def _invalidate_caches(self, change_id: Optional[str] = None) -> None:
         """Drop cached data so the next _load_series re-fetches.
 
-        If change_id is given, only evict that series from the ART
-        cache (branch tips and revision data are cheap dict lookups
-        and get rebuilt from the bulk query anyway).  Without
-        change_id, drop everything.
+        If change_id is given, evict just that series from the ART cache
+        and drop the rest; without change_id, drop the ART cache wholesale
+        too.  Keeping the other series' ART entries is the point of the
+        targeted form: recomputing one is a `git cat-file` per branch.
+
+        The revision caches have to go even for a targeted invalidation:
+        _load_series only refills them when _cached_newest_revisions is
+        None, so leaving them in place pins the version rows, the expander
+        marker and both revision-count gates to pre-action data -- and
+        _check_db_changed cannot heal it, because the caller re-stamps the
+        DB mtime on the way through.
         """
         if change_id is not None:
             branch_name = f'b4/review/{change_id}'
             if self._cached_art_counts and branch_name in self._cached_art_counts:
                 del self._cached_art_counts[branch_name]
+            # Re-resolved: an upgrade renames a branch onto this one and the
+            # batch trusts the SHA it is handed.  Dropped rather than
+            # refetched here: _load_series refills a None dict with the same
+            # `git for-each-ref`, so resolving it inline only moves that
+            # subprocess onto the message pump -- and every handler that
+            # invalidates without reloading pays for a batch it never reads.
+            self._cached_branch_tips = None
+            self._cached_newest_revisions = None
+            self._cached_revisions = None
             return
         self._cached_branch_tips = None
         self._cached_newest_revisions = None
-        self._cached_revision_counts = None
         self._cached_revisions = None
         self._cached_art_counts = None
 
+    def _focus_series(self, change_id: str, keep_version: bool = False) -> None:
+        """Focus a series row on the next rebuild, not one of its versions.
+
+        Clearing the stashed version matters: it outlives a screen that
+        closed without rebuilding the list, and would otherwise drop the
+        cursor onto a child row nobody selected.
+
+        *keep_version* holds the cursor where it already is, for an action
+        that changes the series' status and nothing about which versions it
+        has.  Without it, snoozing from a version row bounced the cursor up
+        to the parent while setting a target branch from the same row left
+        it alone -- two status-only actions disagreeing about where the
+        cursor belongs.  It stays off for the actions that repoint the
+        series at another revision, where the stashed version names a row
+        that may no longer exist.
+        """
+        self._focus_change_id = change_id
+        # Left unset otherwise: the action that focuses a series may be the
+        # one that moved it to another revision, so only the change_id is
+        # reliable.
+        self._focus_series_revision = None
+        self._focus_revision = None
+        if not keep_version:
+            return
+        series = self._selected_series
+        if series is None or series.get('change_id') != change_id:
+            return
+        self._focus_series_revision = series.get('revision')
+        if self._selected_revision is not None:
+            self._focus_revision = self._selected_revision.get('revision')
+
+    @staticmethod
+    def _row_key(series: Dict[str, Any]) -> Tuple[str, int]:
+        """Identity of a list row: a change_id can own more than one."""
+        return (series.get('change_id', ''), series.get('revision', 1))
+
+    def _stash_focus(self) -> None:
+        """Remember the highlighted row so the next _refresh_list restores it.
+
+        Falls back to the row under the cursor when nothing is selected:
+        [escape] clears the selection without moving the cursor, and the
+        list-wide actions that follow ([X]) need no selection at all.  With
+        no hint, _refresh_list restores by absolute index -- into a list
+        whose length [X] has just changed.
+        """
+        series = self._selected_series
+        if series is None:
+            try:
+                item = self.query_one('#tracking-list', ListView).highlighted_child
+            except NoMatches:
+                item = None
+            if isinstance(item, TrackingListItem):
+                series = item.series
+        if series:
+            self._focus_change_id, self._focus_series_revision = self._row_key(series)
+        self._focus_revision = (
+            self._selected_revision.get('revision')
+            if self._selected_revision is not None
+            else None
+        )
+
     def _refresh_msg_count(self, series: Dict[str, Any], total_messages: int) -> None:
         """Opportunistically refresh message count after fetching messages."""
         if not self._identifier:
@@ -1118,6 +1369,9 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
             with Horizontal(classes='details-row', id='detail-revisions-row'):
                 yield Static('Revisions:', classes='details-label')
                 yield Static('', id='detail-revisions', markup=False)
+            with Horizontal(classes='details-row', id='detail-version-row'):
+                yield Static('Version:', classes='details-label')
+                yield Static('', id='detail-version', markup=False)
             with Horizontal(classes='details-row', id='detail-branch-row'):
                 yield Static('Branch:', classes='details-label')
                 yield Static('', id='detail-branch', markup=False)
@@ -1169,10 +1423,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
                     # Reload so the new revisions show up right away; if a
                     # modal is up, the DB mtime poll picks it up instead.
                     if len(self.app.screen_stack) == 1:
-                        if self._selected_series:
-                            self._focus_change_id = self._selected_series.get(
-                                'change_id'
-                            )
+                        self._stash_focus()
                         self._invalidate_caches()
                         self._load_series()
                 elif not conflicts:
@@ -1204,8 +1455,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
                 # pick up the change once the modal closes.
                 if len(self.app.screen_stack) > 1:
                     return
-                if self._selected_series:
-                    self._focus_change_id = self._selected_series.get('change_id')
+                self._stash_focus()
                 self._invalidate_caches()
                 self._load_series()
 
@@ -1239,24 +1489,30 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         except Exception:
             conn = None
         if self._cached_newest_revisions is None and conn:
+            # Both land together or neither: the guard tests the first, so
+            # a partial fill would never be retried and the version rows
+            # would stay gone for the rest of the session.
             try:
-                self._cached_newest_revisions = (
-                    b4.review.tracking.get_all_newest_revisions(conn)
-                )
-                self._cached_revision_counts = (
-                    b4.review.tracking.get_all_revision_counts(conn)
-                )
-                self._cached_revisions = b4.review.tracking.get_all_revisions_grouped(
-                    conn
-                )
+                all_newest = b4.review.tracking.get_all_newest_revisions(conn)
+                all_grouped = b4.review.tracking.get_all_revisions_grouped(conn)
             except Exception:
                 pass
+            else:
+                self._cached_newest_revisions = all_newest
+                self._cached_revisions = all_grouped
         newest_revisions = self._cached_newest_revisions or {}
-        revision_counts = self._cached_revision_counts or {}
         all_revisions = self._cached_revisions or {}
         if conn:
             conn.close()
 
+        # Live rows grouped by change_id, so version merging sees every
+        # revision a sibling row tracks -- the same rule
+        # get_revisions_with_tracked applies on the DB side.  _all_series
+        # is already archive-free.
+        live_rows: Dict[str, List[Dict[str, Any]]] = {}
+        for series in self._all_series:
+            live_rows.setdefault(series.get('change_id', ''), []).append(series)
+
         # --- First pass: branch existence + revision flags ---
         art_branches: Dict[str, str] = {}
         for series in self._all_series:
@@ -1271,17 +1527,21 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
             newest = newest_revisions.get(change_id)
             if newest is not None and newest > current_rev:
                 series['has_newer'] = True
-            rev_count = revision_counts.get(change_id, 0)
-            if rev_count > 1:
-                series['has_multiple_revisions'] = True
-            if rev_count == 0 and series.get('status') not in (
-                'new',
-                'gone',
-                'snoozed',
-            ):
-                series['needs_update'] = True
             # Stash revisions list for the detail panel
             series['_revisions'] = all_revisions.get(change_id, [])
+            series['_sibling_rows'] = live_rows.get(change_id) or [series]
+            # Whether revision data was ever fetched.  The v11 backfill gives
+            # every series row a catalog entry, so discount the one mirroring
+            # this row and fall back to the sweep watermark.
+            rev_count = len(series['_revisions'])
+            if any(r.get('revision') == current_rev for r in series['_revisions']):
+                rev_count -= 1
+            if (
+                rev_count <= 0
+                and not series.get('last_update_check')
+                and series.get('status') not in ('new', 'gone', 'snoozed')
+            ):
+                series['needs_update'] = True
 
             # Collect branches needing ART counts
             if topdir and series.get('status') in (
@@ -1327,6 +1587,25 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
             if branch_name in art_map:
                 series['art'] = art_map[branch_name]
 
+        # _row_key carries the tracked revision, so an upgrade moves a row out
+        # from under its key and silently collapses it.  Migrated only for a
+        # change_id owning one row, the only unambiguous successor.
+        if self._expanded_rows:
+            live: Dict[str, set[int]] = {}
+            for series in self._all_series:
+                cid, rev = self._row_key(series)
+                live.setdefault(cid, set()).add(rev)
+            kept: set[Tuple[str, int]] = set()
+            for cid, rev in self._expanded_rows:
+                revs = live.get(cid)
+                if not revs:
+                    continue
+                if rev in revs:
+                    kept.add((cid, rev))
+                elif len(revs) == 1:
+                    kept.add((cid, next(iter(revs))))
+            self._expanded_rows = kept
+
         # Tag accepted series that have a queued thank-you letter.
         # This is a display-only pseudo-state, not stored in the DB.
         queued_cids = b4.ty.get_queued_change_ids(dryrun=self._email_dryrun)
@@ -1358,8 +1637,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         except OSError:
             return
         if mtime != self._db_mtime:
-            if self._selected_series:
-                self._focus_change_id = self._selected_series.get('change_id')
+            self._stash_focus()
             self._invalidate_caches()
             self._load_series()
 
@@ -1396,12 +1674,16 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
                     return False
         return True
 
+    def _displayed_series(self) -> List[Dict[str, Any]]:
+        """The series the list is currently showing, limit filter applied."""
+        if not self._limit_pattern:
+            return self._all_series
+        return [
+            s for s in self._all_series if self._matches_limit(s, self._limit_pattern)
+        ]
+
     async def _refresh_list(self) -> None:
-        display_series = self._all_series
-        if self._limit_pattern:
-            display_series = [
-                s for s in display_series if self._matches_limit(s, self._limit_pattern)
-            ]
+        display_series = self._displayed_series()
 
         try:
             left = self.query_one('#title-left', Static)
@@ -1419,6 +1701,15 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         left.update(title_text)
 
         scroll_y = ReplacementListView.capture_scroll(self, '#tracking-list')
+        # Where the cursor already is, as the fallback for when no focus
+        # hint is stashed.  An expand queues _refresh_list directly rather
+        # than through _load_series, so two can land in one message-pump
+        # batch and the second would otherwise find the hint consumed and
+        # send the cursor to the top.
+        try:
+            prev_index = self.query_one('#tracking-list', ListView).index or 0
+        except NoMatches:
+            prev_index = 0
 
         # Suppress rendering while we swap old widgets for new ones.
         # Without this, the remove-then-mount sequence can produce a
@@ -1437,27 +1728,95 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
                     id='tracking-empty',
                 )
                 await self.mount(empty, before=self.query_one(Footer))
+                # Nothing is highlighted and this returns above the block that
+                # re-derives the selection, so a stale one would keep acting on
+                # a hidden series.  The focus hint stays: it is only a hint.
+                self._selected_series = None
+                self._selected_revision = None
+                self.refresh_bindings()
                 return
 
             header_text = f'{"Submitter":<20s}{"A":>1s} {"A·R·T":>7s}  {"Msgs":<8s}{"S":<6s}{"Subject"}'
             header = Static(header_text, id='tracking-header')
 
-            list_items: List[ListItem] = [TrackedSeriesItem(s) for s in display_series]
+            # Revisions another live row of the same change_id tracks and
+            # badges itself.  From every live row, not the filtered view: a
+            # sibling the limit hides still owns its revision.
+            tracked_elsewhere: Dict[str, Set[int]] = {}
+            for other in self._all_series:
+                tracked_elsewhere.setdefault(other.get('change_id', ''), set()).add(
+                    other.get('revision', 1)
+                )
+
+            list_items: List[ListItem] = []
+            for series in display_series:
+                revs = self._merge_tracked_revision(series)
+                has_versions = len(revs) > 1
+                tracked = series.get('revision', 1)
+                expanded = has_versions and self._row_key(series) in self._expanded_rows
+                mine = tracked_elsewhere.get(series.get('change_id', ''), set()) - {
+                    tracked
+                }
+                list_items.append(
+                    TrackedSeriesItem(
+                        series,
+                        expanded=expanded,
+                        has_versions=has_versions,
+                        has_unseen_versions=any(
+                            _unseen_delta(
+                                rev.get('message_count'),
+                                rev.get('seen_message_count'),
+                            )
+                            > 0
+                            for rev in revs
+                            if rev.get('revision') != tracked
+                            and rev.get('revision') not in mine
+                        ),
+                    )
+                )
+                if not expanded:
+                    continue
+                for idx, rev in enumerate(revs):
+                    list_items.append(
+                        TrackedRevisionItem(
+                            series,
+                            rev,
+                            is_tracked=rev.get('revision') == tracked,
+                            is_last=idx == len(revs) - 1,
+                        )
+                    )
             lv = ReplacementListView(*list_items, id='tracking-list', scroll_y=scroll_y)
             await self.mount(header, before=self.query_one(Footer))
             await self.mount(lv, before=self.query_one(Footer))
 
-        new_index = 0
+        new_index = min(prev_index, len(list_items) - 1) if list_items else 0
         if self._focus_change_id:
-            for idx, item in enumerate(list_items):
-                if (
-                    isinstance(item, TrackedSeriesItem)
-                    and item.series.get('change_id') == self._focus_change_id
-                ):
-                    new_index = idx
-                    break
+            parents = [
+                (idx, item)
+                for idx, item in enumerate(list_items)
+                if isinstance(item, TrackedSeriesItem)
+                and item.series.get('change_id') == self._focus_change_id
+            ]
+            # A change_id can own more than one live row.  Prefer the one whose
+            # revision was stashed, and fall back to the first: an upgrade
+            # moves the row's revision out from under the stash, and
+            # _focus_series() deliberately stashes no revision at all.
+            chosen = next(
+                (
+                    idx
+                    for idx, item in parents
+                    if item.series.get('revision') == self._focus_series_revision
+                ),
+                parents[0][0] if parents else None,
+            )
+            if chosen is not None:
+                new_index = chosen
+                if self._focus_revision is not None:
+                    new_index = self._find_focus_child(list_items, chosen)
             self._focus_change_id = None
+            self._focus_series_revision = None
         lv.index = new_index
+        self._focus_revision = None
         lv.focus()
 
         # Populate the details panel for the highlighted item now that
@@ -1466,10 +1825,29 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         # Also sync _selected_series here so action_action() reads fresh
         # status without waiting for the async Highlighted message to
         # be processed from the queue.
-        highlighted = lv.highlighted_child
-        if isinstance(highlighted, TrackedSeriesItem):
-            self._selected_series = highlighted.series
-            self._show_details(highlighted.series)
+        self._select(lv.highlighted_child)
+
+    def _select(self, item: Optional[ListItem]) -> None:
+        """Make *item* the selection and mirror it into the details panel."""
+        if not isinstance(item, TrackingListItem):
+            return
+        self._selected_series, self._selected_revision = item.selection
+        self._show_details(self._selected_series, rev=self._selected_revision)
+
+    def _find_focus_child(self, list_items: List[ListItem], parent_idx: int) -> int:
+        """Index of the _focus_revision child row below *parent_idx*.
+
+        Falls back to the parent index when that version is no longer
+        listed — it may have gone away, or the series may have been
+        collapsed since the focus was stashed.
+        """
+        for idx in range(parent_idx + 1, len(list_items)):
+            item = list_items[idx]
+            if not isinstance(item, TrackedRevisionItem):
+                break
+            if item.rev.get('revision') == self._focus_revision:
+                return idx
+        return parent_idx
 
     def action_limit(self) -> None:
         self.push_screen(
@@ -1484,8 +1862,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         if result is None:
             return
         self._limit_pattern = result
-        if self._selected_series:
-            self._focus_change_id = self._selected_series.get('change_id')
+        self._stash_focus()
         self._load_series()
 
     def action_cursor_down(self) -> None:
@@ -1500,17 +1877,55 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         except Exception:
             pass
 
+    def action_toggle_expand(self) -> None:
+        """Show or hide the version rows of the selected series."""
+        series = self._selected_series
+        if not series or len(self._merge_tracked_revision(series)) < 2:
+            return
+        key = self._row_key(series)
+        if key in self._expanded_rows:
+            self._expanded_rows.discard(key)
+            # The highlighted version row is about to go away
+            self._selected_revision = None
+        else:
+            self._expanded_rows.add(key)
+        self._stash_focus()
+        self.call_later(self._refresh_list)
+
+    def action_expand_all(self) -> None:
+        """Expand every multi-version series, or collapse them all."""
+        # Scoped to what the limit filter is showing: judging "are they all
+        # expanded already?" against hidden rows makes the first press
+        # expand nothing the maintainer can see.
+        expandable = {
+            self._row_key(s)
+            for s in self._displayed_series()
+            if len(self._merge_tracked_revision(s)) > 1
+        }
+        if not expandable:
+            return
+        if expandable - self._expanded_rows:
+            self._expanded_rows |= expandable
+        else:
+            # Collapsing is scoped the same way, or a series the filter
+            # hides silently folds back up.
+            self._expanded_rows -= expandable
+            self._selected_revision = None
+        self._stash_focus()
+        self.call_later(self._refresh_list)
+
     def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
         if event.list_view.id != 'tracking-list':
             return
-        item = event.item
-        if isinstance(item, TrackedSeriesItem):
-            self._selected_series = item.series
-            self._show_details(item.series)
+        self._select(event.item)
         self.refresh_bindings()
 
     def on_list_view_selected(self, event: ListView.Selected) -> None:
         if event.list_view.id == 'tracking-list':
+            # A version row opens that version's thread
+            if isinstance(event.item, TrackedRevisionItem):
+                self.action_thread()
+                return
             if not self._selected_series:
                 return
             status = self._selected_series.get('status', 'new')
@@ -1568,12 +1983,35 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         'snoozed': frozenset(
             {'review', 'range_diff', 'unsnooze', 'abandon', 'target_branch'}
         ),
-        'thanked': frozenset({'review', 'archive'}),
-        'gone': frozenset({'abandon', 'review'}),
+        # range_diff is available wherever version rows are: [x] expands a
+        # series in any state, and 'd' on a child row is documented to work.
+        # It needs no branch -- compute_range_diff reconstructs both sides
+        # from the catalog.
+        'thanked': frozenset({'review', 'range_diff', 'archive'}),
+        'gone': frozenset({'abandon', 'review', 'range_diff'}),
     }
     # All state-gated actions (union of all per-state sets)
     _GATED_ACTIONS = frozenset().union(*_STATE_ACTIONS.values())
 
+    # Acts on the revision the series tracks, not the row under the cursor:
+    # refused on another version's row by both the key and the action menu.
+    # 'review' checks the review branch out; 'take' and 'rebase' operate on
+    # that same branch, built from the tracked revision; 'upgrade' archives
+    # it, applies the newer revision and renames the result back.  All four
+    # would otherwise run against a version the cursor is not on.
+    #
+    # 'upgrade' is also gated in check_action directly, which returns for it
+    # before this frozenset is consulted; this entry covers the action menu.
+    _TRACKED_ONLY_ACTIONS = frozenset({'review', 'take', 'rebase', 'upgrade'})
+
+    def _on_other_version(self) -> bool:
+        """Whether the cursor is on a version row that is not the tracked one."""
+        if self._selected_revision is None or self._selected_series is None:
+            return False
+        return self._selected_revision.get('revision') != self._selected_series.get(
+            'revision'
+        )
+
     def check_action(self, action: str, parameters: Tuple[Any, ...]) -> Optional[bool]:
         """Hide status-specific actions based on the selected series."""
         if action == 'process_queue':
@@ -1588,8 +2026,20 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
             # any state that can hold one (including accepted/thanked, e.g. a
             # series kept back from auto-archiving because a newer revision
             # appeared), not only a checked-out 'reviewing' branch.
+            #
+            # It still moves the review branch, though -- archive, git am,
+            # rename -- against the revision the series tracks, so it is
+            # refused on another version's row for the same reason review,
+            # take and rebase are.  It cannot go in _TRACKED_ONLY_ACTIONS:
+            # this branch returns before that check is reached.
+            return (
+                bool(self._selected_series and self._selected_series.get('has_newer'))
+                and not self._on_other_version()
+            )
+        if action == 'toggle_expand':
             return bool(
-                self._selected_series and self._selected_series.get('has_newer')
+                self._selected_series
+                and len(self._merge_tracked_revision(self._selected_series)) > 1
             )
         if action in self._GATED_ACTIONS:
             if not self._selected_series:
@@ -1597,8 +2047,18 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
             status = self._selected_series.get('status', 'new')
             if action not in self._STATE_ACTIONS.get(status, frozenset()):
                 return False
+            if action in self._TRACKED_ONLY_ACTIONS and self._on_other_version():
+                # These act on the review branch, which holds the revision
+                # the series tracks and not the one the cursor is sitting
+                # on.  Every other key acting on the parent is harmless;
+                # these build or move a branch, so they are greyed out
+                # rather than quietly doing that for a different version.
+                return False
             if action == 'range_diff':
-                return bool(self._selected_series.get('has_multiple_revisions'))
+                # Same predicate as toggle_expand: a raw catalog count
+                # misses a tracked revision the catalog never recorded,
+                # leaving an expanded version row with 'd' disabled.
+                return len(self._merge_tracked_revision(self._selected_series)) > 1
             if action == 'target_branch':
                 return self._has_target_branches
             return True
@@ -1657,6 +2117,13 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
             if status != 'thanked':
                 actions.append(('abandon', 'Abandon series'))
             actions.append(('archive', 'Archive series'))
+        if self._on_other_version():
+            # _on_action_selected dispatches directly, so Textual never
+            # consults check_action for a menu pick.  Only this rule, though:
+            # the menu deliberately offers actions no key binds.
+            actions = [
+                entry for entry in actions if entry[0] not in self._TRACKED_ONLY_ACTIONS
+            ]
         self.push_screen(
             ActionScreen(actions, shortcuts=_ACTION_SHORTCUTS),
             callback=self._on_action_selected,
@@ -1822,10 +2289,19 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         self._checkout_new_series()
 
     def action_thread(self) -> None:
-        """View a series thread in the lite thread viewer."""
+        """View a series thread in the lite thread viewer.
+
+        A highlighted version row views that version's thread; otherwise
+        the thread of the revision the series tracks.
+        """
         if not self._selected_series:
             return
-        message_id = self._selected_series.get('message_id', '')
+        source = (
+            self._selected_revision
+            if self._selected_revision is not None
+            else self._selected_series
+        )
+        message_id = source.get('message_id', '')
         if not message_id:
             self.notify('No message-id available for this series', severity='error')
             return
@@ -1834,10 +2310,16 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
             tracking_info = {
                 'identifier': self._identifier,
                 'change_id': self._selected_series.get('change_id', ''),
-                'revision': self._selected_series.get('revision', 1),
-                'is_rethreaded': bool(self._selected_series.get('is_rethreaded')),
+                'revision': source.get('revision', 1),
+                'is_rethreaded': bool(source.get('is_rethreaded')),
             }
-        self._focus_change_id = self._selected_series.get('change_id')
+        # No _stash_focus() here.  Viewing a thread rebuilds nothing by
+        # itself -- re-reading an already-read thread writes nothing, so the
+        # DB-mtime poll does not fire -- and the hint would then outlive the
+        # screen and be spent by whatever reloads next, parking the cursor
+        # on this series' version row from an unrelated action.  The only
+        # rebuild that can follow is _check_db_changed's, which stashes for
+        # itself.
         from b4.review_tui._lite_app import LiteThreadScreen
 
         self.push_screen(
@@ -2225,8 +2707,17 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         # Exit to review mode
         self.exit(branch_name)
 
-    def _show_details(self, series: Dict[str, Any]) -> None:
+    def _show_details(
+        self, series: Dict[str, Any], rev: Optional[Dict[str, Any]] = None
+    ) -> None:
+        """Fill the details panel for *series*, or for one of its versions.
 
+        With *rev* given the version-specific fields (subject, link) come
+        from that revision instead of the tracked one.  Fields the catalog
+        does not carry per revision -- the patch count and the sent date --
+        are suppressed rather than filled in from the tracked revision,
+        which would attribute another version's numbers to this one.
+        """
         try:
             panel = self.query_one('#details-panel', Vertical)
         except NoMatches:
@@ -2234,11 +2725,17 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
 
         raw_subject = series.get('subject', '(no subject)')
         revision = series.get('revision', 1)
+        other_version = rev is not None and rev.get('revision') != revision
+        if rev is not None:
+            raw_subject = rev.get('subject') or raw_subject
+            revision = rev.get('revision', revision)
         num_patches = series.get('num_patches', 0) or 0
         ls = b4.LoreSubject(raw_subject)
         extras = ls.get_extra_prefixes(exclude=['patch'])
         width = len(str(num_patches)) if num_patches > 0 else 1
-        parts = extras + [f'v{revision}', f'{"0" * width}/{num_patches:0{width}d}']
+        parts = extras + [f'v{revision}']
+        if not other_version:
+            parts.append(f'{"0" * width}/{num_patches:0{width}d}')
         subject = f'[{",".join(parts)}] {ls.subject}'
 
         sender_name = series.get('sender_name', 'Unknown')
@@ -2249,15 +2746,21 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
 
         # Create link URL from message-id using linkmask
         link_url = ''
-        if message_id:
+        if rev is not None:
+            link_url = rev.get('link') or ''
+            message_id = rev.get('message_id', '')
+        if not link_url and message_id:
             config = b4.get_main_config()
             linkmask = config.get('linkmask', b4.LOREADDR + '/%s')
             if isinstance(linkmask, str) and '%s' in linkmask:
                 link_url = linkmask % message_id
 
-        # Convert ISO date to RFC 822 in local timezone
+        # Convert ISO date to RFC 822 in local timezone.  Only the tracked
+        # revision has a recorded send date; for the others the Version:
+        # row below reports what the catalog does know (first seen, last
+        # activity).
         sent_str = 'Unknown'
-        sent_at = series.get('sent_at', '')
+        sent_at = '' if other_version else series.get('sent_at', '')
         if sent_at:
             try:
                 dt = datetime.datetime.fromisoformat(sent_at)
@@ -2282,11 +2785,15 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         self.query_one('#detail-changeid', Static).update(change_id)
         self.query_one('#detail-link', Static).update(link_url)
 
-        # Attestation row
-        att = series.get('attestation') or ''
+        # Attestation row.  Stored per series row, so it describes the
+        # tracked revision only -- showing it beside another version's
+        # subject would report that version as signed and verified.
+        att = '' if other_version else (series.get('attestation') or '')
         att_row = self.query_one('#detail-attestation-row', Horizontal)
         att_widget = self.query_one('#detail-attestation', Static)
-        if att == 'pending' or att == '':
+        if other_version:
+            att_row.display = False
+        elif att == 'pending' or att == '':
             att_widget.update(RichText('pending (run [u]pdate)', style='dim'))
             att_row.display = True
         elif att == 'none':
@@ -2300,12 +2807,21 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
             else:
                 att_row.display = False
 
-        # Show known revisions (precomputed in _load_series)
+        # Show known revisions (precomputed in _load_series).  Merged the
+        # same way the version rows are, so the list cannot disagree with
+        # the rows displayed directly above it.
         revisions_row = self.query_one('#detail-revisions-row', Horizontal)
-        revs = series.get('_revisions', [])
-        if revs:
+        revs = self._merge_tracked_revision(series)
+        rev_widget = self.query_one('#detail-revisions', Static)
+        if series.get('needs_update'):
+            # Checked before the list, not after: the merged list always
+            # carries at least the tracked revision, so an `if revs:` would
+            # shadow this hint and leave the row's '*' flag unexplained.
+            rev_widget.add_class('has-upgrade')
+            rev_widget.update('run [u]pdate to load revision data')
+            revisions_row.display = True
+        elif revs:
             rev_str = ', '.join(f'v{r["revision"]}' for r in revs)
-            rev_widget = self.query_one('#detail-revisions', Static)
             if series.get('has_newer'):
                 newest = max(r['revision'] for r in revs)
                 rev_str += f' (v{newest} available — upgrade with [a]ction)'
@@ -2315,13 +2831,17 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
             rev_widget.update(rev_str)
             revisions_row.display = True
         else:
-            if series.get('needs_update'):
-                rev_widget = self.query_one('#detail-revisions', Static)
-                rev_widget.add_class('has-upgrade')
-                rev_widget.update('run [u]pdate to load revision data')
-                revisions_row.display = True
-            else:
-                revisions_row.display = False
+            revisions_row.display = False
+
+        # Describe the highlighted version row, if any
+        version_row = self.query_one('#detail-version-row', Horizontal)
+        if rev is not None:
+            self.query_one('#detail-version', Static).update(
+                _format_version(rev, series)
+            )
+            version_row.display = True
+        else:
+            version_row.display = False
 
         # Show branch name for series with a review branch
         status = series.get('status', 'new')
@@ -2485,8 +3005,11 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         else:
             self.notify('Target branch cleared')
 
-        # Refresh details panel
-        self._show_details(series)
+        # Refresh details panel, for the row the cursor is actually on:
+        # dropping the version here reverts the panel to the tracked
+        # revision's subject and attestation while a version row is still
+        # highlighted.
+        self._show_details(series, rev=self._selected_revision)
 
     def action_update_one(self) -> None:
         """Fetch thread and update revisions/trailers for the selected series."""
@@ -2498,7 +3021,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         linkmask = str(config.get('linkmask', 'https://lore.kernel.org/r/%s'))
         topdir = b4.git_get_toplevel()
 
-        self._focus_change_id = self._selected_series.get('change_id')
+        self._stash_focus()
         self.push_screen(
             UpdateAllScreen(
                 [self._selected_series],
@@ -2523,8 +3046,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         # Skip snoozed series during update-all
         update_list = [s for s in self._all_series if s.get('status') != 'snoozed']
 
-        if self._selected_series:
-            self._focus_change_id = self._selected_series.get('change_id')
+        self._stash_focus()
         self.push_screen(
             UpdateAllScreen(update_list, self._identifier, linkmask, topdir),
             callback=self._on_update_complete,
@@ -2579,6 +3101,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         if self._selected_series is not None:
             panel.styles.height = 0
             self._selected_series = None
+            self._selected_revision = None
 
     def action_take(self) -> None:
         """Show take options dialog for the selected series."""
@@ -2856,7 +3379,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         if not confirmed:
             return
         take_screen.accept_series = confirm_screen.accept_series
-        self._focus_change_id = change_id
+        self._focus_series(change_id)
         self._invalidate_caches(change_id)
         if method == 'merge':
             with self.suspend():
@@ -3844,9 +4367,22 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         change_id = self._selected_series.get('change_id', '')
         current_rev = self._selected_series.get('revision', 1)
 
+        # A highlighted version row already names the other side of the
+        # diff, so skip the picker.
+        if self._selected_revision is not None:
+            other_rev = self._selected_revision.get('revision')
+            if other_rev is not None and other_rev != current_rev:
+                with self.suspend():
+                    self._do_range_diff(change_id, current_rev, other_rev)
+                return
+
         try:
             conn = b4.review.tracking.get_db(self._identifier)
-            revisions = b4.review.tracking.get_revisions(conn, change_id)
+            # The same set the [d] gate counts and compute_range_diff
+            # resolves against: a raw catalog read misses a revision only a
+            # sibling series row names, and offers a picker with nothing in
+            # it on a row the binding was enabled for.
+            revisions = b4.review.tracking.get_revisions_with_tracked(conn, change_id)
             conn.close()
         except Exception as ex:
             self.notify(f'Could not load revisions: {ex}', severity='error')
@@ -4190,7 +4726,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
             self.notify(f'Linked v{rev} (absorbed a duplicate)')
         else:
             self.notify(f'Linked v{rev}')
-        self._focus_change_id = change_id
+        self._focus_series(change_id)
         self._invalidate_caches(change_id)
         self._load_series()
 
@@ -4214,7 +4750,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
             self.notify(f'Could not update revision: {ex}', severity='error')
             return
         self.notify(f'Now tracking v{target_rev}')
-        self._focus_change_id = change_id
+        self._focus_series(change_id)
         self._invalidate_caches(change_id)
         self._load_series()
 
@@ -4695,7 +5231,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
             _wait_for_enter()
 
         # Return to the tracking list with the upgraded series focused
-        self._focus_change_id = change_id
+        self._focus_series(change_id)
         self._invalidate_caches(change_id)
         self._load_series()
 
@@ -4722,7 +5258,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
             branch_name = f'b4/review/{change_id}'
             b4.review.update_tracking_status(topdir, branch_name, 'waiting')
         self.notify('Series moved to waiting')
-        self._focus_change_id = change_id
+        self._focus_series(change_id, keep_version=True)
         self._invalidate_caches(change_id)
         self._load_series()
 
@@ -4788,7 +5324,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
         self._last_snooze_input = result.get('input', '')
 
         self.notify(f'Snoozed, {_format_snooze_until(until_value)}')
-        self._focus_change_id = change_id
+        self._focus_series(change_id, keep_version=True)
         self._invalidate_caches(change_id)
         self._load_series()
 
@@ -4825,7 +5361,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
             return
 
         self.notify(f'Unsnoozed, restored to {previous_status}')
-        self._focus_change_id = change_id
+        self._focus_series(change_id, keep_version=True)
         self._invalidate_caches(change_id)
         self._load_series()
 
@@ -5205,7 +5741,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
                     b4.review.update_tracking_status(topdir, review_branch, 'thanked')
             if archive_after:
                 self._archive_after_thanks(series)
-            self._focus_change_id = change_id
+            self._focus_series(change_id, keep_version=True)
             self._invalidate_caches(change_id)
             self._load_series()
         except Exception as ex:
@@ -5293,8 +5829,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
             self.notify(', '.join(parts) if parts else 'Queue empty')
             self._refresh_queue_indicator()
             if delivered_series:
-                if self._selected_series:
-                    self._focus_change_id = self._selected_series.get('change_id')
+                self._stash_focus()
                 self._load_series()
 
         self.push_screen(
diff --git a/src/tests/test_review_tracking.py b/src/tests/test_review_tracking.py
index 532c65c9..03aa3407 100644
--- a/src/tests/test_review_tracking.py
+++ b/src/tests/test_review_tracking.py
@@ -1068,17 +1068,6 @@ class TestRevisions:
         assert review_tracking.get_all_newest_revisions(conn) == {}
         conn.close()
 
-    def test_get_all_revision_counts(self, tmp_path: pytest.TempPathFactory) -> None:
-        """Verify bulk revision-count query returns correct counts."""
-        conn = review_tracking.init_db('rev-bulk-count-test')
-        review_tracking.add_revision(conn, 'change-a', 1, 'a-v1@example.com')
-        review_tracking.add_revision(conn, 'change-a', 2, 'a-v2@example.com')
-        review_tracking.add_revision(conn, 'change-a', 3, 'a-v3@example.com')
-        review_tracking.add_revision(conn, 'change-b', 1, 'b-v1@example.com')
-        result = review_tracking.get_all_revision_counts(conn)
-        assert result == {'change-a': 3, 'change-b': 1}
-        conn.close()
-
     def test_get_all_revisions_grouped(self, tmp_path: pytest.TempPathFactory) -> None:
         """Verify bulk grouped revisions returns correct per-change-id lists."""
         conn = review_tracking.init_db('rev-bulk-grouped-test')
diff --git a/src/tests/test_tui_tracking.py b/src/tests/test_tui_tracking.py
index d6233afe..7ba2443e 100644
--- a/src/tests/test_tui_tracking.py
+++ b/src/tests/test_tui_tracking.py
@@ -4663,7 +4663,7 @@ class TestLoadSeriesCaching:
             await pilot.pause()
             assert app._cached_branch_tips is not None
             assert app._cached_newest_revisions is not None
-            assert app._cached_revision_counts is not None
+            assert app._cached_revisions is not None
 
     @pytest.mark.asyncio
     async def test_caches_survive_db_poll_no_change(
@@ -4697,14 +4697,26 @@ class TestLoadSeriesCaching:
             # https://github.com/python/mypy/issues/9457:
             # app._cached_branch_tips is stale-narrowed across a method call.
             assert app._cached_newest_revisions is None  # type: ignore[unreachable]
-            assert app._cached_revision_counts is None
+            assert app._cached_revisions is None
             assert app._cached_art_counts is None
 
     @pytest.mark.asyncio
     async def test_selective_invalidation_keeps_other_caches(
         self, tmp_path: pathlib.Path
     ) -> None:
-        """_invalidate_caches(change_id) only evicts that ART entry."""
+        """_invalidate_caches(change_id) evicts one ART entry and the revisions.
+
+        The revision caches have to go: _load_series only refills them when
+        _cached_newest_revisions is None, so keeping them pins the version
+        rows and both revision-count gates to pre-action data.
+
+        What the targeted form protects is the *other* series' ART entries,
+        each of which costs a `git cat-file` to rebuild.  The branch tips
+        are one `git for-each-ref` for all of them, and _load_series already
+        refills a None dict with it -- resolving them here instead only put
+        that subprocess on the message pump, in a handler that may never
+        reload at all.
+        """
         _seed_db('cache-sel-inv', SAMPLE_SERIES)
 
         app = TrackingApp('cache-sel-inv')
@@ -4719,9 +4731,12 @@ class TestLoadSeriesCaching:
             # Alpha evicted, bravo still there
             assert 'b4/review/test-change-alpha' not in app._cached_art_counts
             assert 'b4/review/test-change-bravo' in app._cached_art_counts
-            # Other caches untouched
-            assert app._cached_branch_tips is not None
-            assert app._cached_newest_revisions is not None
+            # Branch tips are dropped, not re-resolved inline: one
+            # for-each-ref refills them, and _load_series already runs it.
+            assert app._cached_branch_tips is None
+            # Revision data is dropped so the next load re-reads it
+            assert app._cached_newest_revisions is None
+            assert app._cached_revisions is None
 
     @pytest.mark.asyncio
     async def test_revisions_stashed_in_series(self, tmp_path: pathlib.Path) -> None:

-- 
2.53.0


^ permalink raw reply related	[flat|nested] 26+ messages in thread

* [PATCH RFC v2 25/25] review-tui: test per-version tracker rows
  2026-08-12 21:46 [PATCH RFC v2 00/25] review: track and browse every version of a tracked series Christian Brauner
                   ` (23 preceding siblings ...)
  2026-08-12 21:47 ` [PATCH RFC v2 24/25] review-tui: expand tracked series into per-version rows Christian Brauner
@ 2026-08-12 21:47 ` Christian Brauner
  24 siblings, 0 replies; 26+ messages in thread
From: Christian Brauner @ 2026-08-12 21:47 UTC (permalink / raw)
  To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Christian Brauner (Amutable)

Cover the expansion affordance and toggling, tracked-revision marking,
child-row selection state, survival of expansion and cursor position
across DB reloads and limit filtering, child-row thread opening with the
right revision, direct child range-diff, expand-all, the details-panel
version row, and NULL-count rendering.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 src/tests/test_tui_tracking.py | 1629 +++++++++++++++++++++++++++++++++++++++-
 1 file changed, 1626 insertions(+), 3 deletions(-)

diff --git a/src/tests/test_tui_tracking.py b/src/tests/test_tui_tracking.py
index 7ba2443e..17cdf41a 100644
--- a/src/tests/test_tui_tracking.py
+++ b/src/tests/test_tui_tracking.py
@@ -11,11 +11,14 @@ core user workflows: series listing, navigation, filtering,
 status transitions, and modal interactions.
 """
 
+import contextlib
 import datetime
 import email.message
 import os
 import pathlib
+import re
 import sqlite3
+import time
 from typing import Any, Callable, Dict, List, Optional, Tuple
 from unittest.mock import patch
 
@@ -23,7 +26,7 @@ import pytest
 
 pytest.importorskip('textual')
 
-from textual.widgets import Input, ListView, Static
+from textual.widgets import Input, Label, ListView, Static
 
 import b4
 import b4.review
@@ -36,6 +39,7 @@ from b4 import (
     _worktree_inprogress_op,
     _worktree_merge_in_progress,
 )
+from b4.review_tui._lite_app import LiteThreadScreen
 from b4.review_tui._modals import (
     ActionItem,
     ActionScreen,
@@ -45,17 +49,21 @@ from b4.review_tui._modals import (
     HelpScreen,
     LimitScreen,
     LinkRevisionScreen,
+    RangeDiffScreen,
     RebaseScreen,
     SnoozeScreen,
     TakeConfirmScreen,
     TargetBranchScreen,
 )
 from b4.review_tui._tracking_app import (
+    TrackedRevisionItem,
     TrackedSeriesItem,
     TrackingApp,
     _build_base_suggestions,
     _detect_initial_base,
     _effective_tier,
+    _format_version,
+    _msgs_fields,
     _resolve_worktree_take_conflict,
     _shazam_merge_flags,
     _take_worktree,
@@ -5995,8 +6003,6 @@ class TestRethreadFlagReachesTheThreadFetch:
         self, tmp_path: pathlib.Path
     ) -> None:
         """The viewer's series dict is what selects the reassembly path."""
-        from b4.review_tui._lite_app import LiteThreadScreen
-
         seen: Dict[str, Any] = {}
 
         def _capture(series: Dict[str, Any], identifier: str) -> List[Any]:
@@ -6017,6 +6023,27 @@ class TestRethreadFlagReachesTheThreadFetch:
         assert seen['is_rethreaded'] is True
         assert seen['revision'] == 2
 
+    @pytest.mark.asyncio
+    async def test_a_plain_version_row_reports_its_own_flag(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """v1 is not rethreaded, so its row must not inherit v2's flag."""
+        self._seed_rethreaded('rt-child')
+
+        app = TrackingApp('rt-child')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            await pilot.press('j')
+            await pilot.pause()
+            with patch.object(app, 'push_screen') as mock_push:
+                await pilot.press('e')
+                await pilot.pause()
+            screen = mock_push.call_args[0][0]
+            assert screen._tracking_info['revision'] == 1
+            assert screen._tracking_info['is_rethreaded'] is False
+
 
 class TestUpdateAllDoesNotForceThePoll:
     """'u' asks about one series; 'U' must not force the schedule everywhere.
@@ -6064,6 +6091,45 @@ class TestDiscoverOlderAction:
             keys = [key for key, _label in getattr(app.screen, '_actions')]
             assert 'discover' in keys
 
+    @pytest.mark.asyncio
+    async def test_conflicts_suppress_the_nothing_found_notice(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """A run that skipped every version it found reports 0 found.
+
+        Saying "No older revisions found" and then listing four of them is
+        two notifications contradicting each other in the same toast stack.
+        """
+        _seed_db(
+            'test-discover-both',
+            [{'change_id': 'cid-d', 'revision': 5, 'status': 'new'}],
+        )
+        notices: List[str] = []
+
+        app = TrackingApp('test-discover-both')
+        with patch.object(
+            tracking,
+            'discover_older_revisions',
+            lambda *a, **kw: {
+                'found': 0,
+                'revisions': [],
+                'conflicts': [1, 2],
+                'error': None,
+            },
+        ):
+            async with app.run_test(size=(120, 30)) as pilot:
+                await pilot.pause()
+                with patch.object(
+                    TrackingApp,
+                    'notify',
+                    lambda self, msg, **kw: notices.append(str(msg)),
+                ):
+                    app.action_discover_older()
+                    await app.workers.wait_for_complete()
+                    await pilot.pause()
+        assert not any('No older revisions found' in n for n in notices), notices
+        assert any('v1, v2' in n for n in notices), notices
+
     @pytest.mark.asyncio
     async def test_action_runs_discovery(self, tmp_path: pathlib.Path) -> None:
         _seed_db(
@@ -6092,3 +6158,1560 @@ class TestDiscoverOlderAction:
                 await app.workers.wait_for_complete()
                 await pilot.pause()
         assert calls == [('test-discover-run', 'cid-d')]
+
+
+def _seed_multiver(
+    identifier: str,
+    change_id: str = 'multi-1',
+    tracked: int = 2,
+    revisions: Optional[List[int]] = None,
+) -> None:
+    """Seed a series tracking v*tracked* with *revisions* in the catalog.
+
+    The tracked revision has message counts (5 total, 2 unseen); the
+    other revisions were never fetched, so their counts stay NULL.
+    """
+    conn = tracking.init_db(identifier)
+    tracking.add_series_to_db(
+        conn,
+        change_id=change_id,
+        revision=tracked,
+        subject=f'[PATCH v{tracked} 0/2] multi: test series',
+        sender_name='Vera Version',
+        sender_email='vera@example.com',
+        sent_at='2026-03-10T10:00:00+00:00',
+        message_id=f'{change_id}-v{tracked}@example.com',
+        num_patches=2,
+    )
+    conn.execute(
+        'UPDATE revisions SET message_count = 5, seen_message_count = 3'
+        ' WHERE change_id = ? AND revision = ?',
+        (change_id, tracked),
+    )
+    conn.commit()
+    for rev in [1, 2, 3] if revisions is None else revisions:
+        tracking.add_revision(
+            conn,
+            change_id,
+            rev,
+            f'{change_id}-v{rev}@example.com',
+            subject=f'[PATCH v{rev} 0/2] multi: test series',
+        )
+    conn.close()
+
+
+def _selected_rev(app: TrackingApp) -> Optional[Any]:
+    """Read _selected_revision without narrowing it for the rest of the test."""
+    return app._selected_revision
+
+
+def _version_row_shown(app: TrackingApp) -> bool:
+    """Whether the details panel's Version: row is currently displayed."""
+    return bool(app.query_one('#detail-version-row').display)
+
+
+def _list_items(app: TrackingApp) -> List[Any]:
+    """Every row currently in the tracking list, parents and children."""
+    return list(app.query_one('#tracking-list', ListView).children)
+
+
+def _row_text(item: Any) -> str:
+    """The rendered text of a single tracking list row."""
+    return _static_text(item.query_one(Label))
+
+
+def _msgs_column(text: str) -> str:
+    """The Msgs column of a rendered row (total + unseen badge).
+
+    Fixed offset: 20 (submitter) + 1 (attestation) + 1 + 7 (A·R·T) = 29,
+    then 5 for the total, a separator and 4 for the badge.  Slicing it
+    asserts that parent and child rows agree on the column layout.
+    """
+    return text[29:39].strip()
+
+
+class TestVersionExpansion:
+    """Tests for expanding a series into per-version child rows."""
+
+    @pytest.mark.asyncio
+    async def test_affordance_only_with_multiple_versions(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """Only a series with more than one known version gets the marker."""
+        _seed_multiver('expand-affordance')
+        conn = tracking.get_db('expand-affordance')
+        tracking.add_series_to_db(
+            conn,
+            change_id='single-1',
+            revision=1,
+            subject='[PATCH] single: just one version',
+            sender_name='Sam Single',
+            sender_email='sam@example.com',
+            sent_at='2026-03-11T10:00:00+00:00',
+            message_id='single-v1@example.com',
+            num_patches=1,
+        )
+        tracking.add_revision(conn, 'single-1', 1, 'single-v1@example.com')
+        conn.close()
+
+        app = TrackingApp('expand-affordance')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            rows = {i.series['change_id']: _row_text(i) for i in _list_items(app)}
+            assert '▸' in rows['multi-1']
+            assert '▸' not in rows['single-1']
+
+    @pytest.mark.asyncio
+    async def test_x_expands_into_child_rows(self, tmp_path: pathlib.Path) -> None:
+        """x adds one child row per known version, oldest first."""
+        _seed_multiver('expand-toggle')
+
+        app = TrackingApp('expand-toggle')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            assert len(_list_items(app)) == 1
+
+            await pilot.press('x')
+            await pilot.pause()
+            items = _list_items(app)
+            assert len(items) == 4
+            assert isinstance(items[0], TrackedSeriesItem)
+            assert items[0].expanded
+            assert '▾' in _row_text(items[0])
+            children = items[1:]
+            assert all(isinstance(c, TrackedRevisionItem) for c in children)
+            assert [c.rev['revision'] for c in children] == [1, 2, 3]
+
+    @pytest.mark.asyncio
+    async def test_x_collapses_again(self, tmp_path: pathlib.Path) -> None:
+        """A second x hides the child rows and restores the parent cursor."""
+        _seed_multiver('expand-collapse')
+
+        app = TrackingApp('expand-collapse')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            # Collapse from a child row — focus must return to the parent
+            await pilot.press('j')
+            await pilot.pause()
+            assert app._selected_revision is not None
+
+            await pilot.press('x')
+            await pilot.pause()
+            items = _list_items(app)
+            assert len(items) == 1
+            assert '▸' in _row_text(items[0])
+            assert _selected_rev(app) is None
+            assert app.query_one('#tracking-list', ListView).index == 0
+
+    @pytest.mark.asyncio
+    async def test_tracked_version_is_marked(self, tmp_path: pathlib.Path) -> None:
+        """The child row for the tracked revision carries an asterisk."""
+        _seed_multiver('expand-marker')
+
+        app = TrackingApp('expand-marker')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            children = _list_items(app)[1:]
+            marked = [c for c in children if c.is_tracked]
+            assert len(marked) == 1
+            assert marked[0].rev['revision'] == 2
+            assert 'v2*' in _row_text(marked[0])
+            assert 'v1*' not in _row_text(children[0])
+            assert 'v3*' not in _row_text(children[2])
+
+    @pytest.mark.asyncio
+    async def test_highlighting_child_keeps_parent_series(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """A child row selects its version, but the series stays the parent."""
+        _seed_multiver('expand-select')
+
+        app = TrackingApp('expand-select')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+
+            await pilot.press('j')
+            await pilot.pause()
+            sel_rev = _selected_rev(app)
+            assert sel_rev is not None
+            assert sel_rev['revision'] == 1
+            assert app._selected_series is not None
+            assert app._selected_series['change_id'] == 'multi-1'
+
+            # Back on the parent row the version selection is cleared
+            await pilot.press('k')
+            await pilot.pause()
+            assert _selected_rev(app) is None
+            assert app._selected_series is not None
+            assert app._selected_series['change_id'] == 'multi-1'
+
+    @pytest.mark.asyncio
+    async def test_expansion_and_cursor_survive_db_reload(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """An external DB change rebuilds the list without losing the child."""
+        _seed_multiver('expand-reload')
+
+        app = TrackingApp('expand-reload')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            await pilot.press('j')
+            await pilot.press('j')
+            await pilot.pause()
+            assert app._selected_revision is not None
+            assert app._selected_revision['revision'] == 2
+
+            # Bump the mtime the way another b4 process writing the DB would
+            db_path = tracking.get_db_path('expand-reload')
+            stamp = os.path.getmtime(db_path) + 10
+            os.utime(db_path, (stamp, stamp))
+            app._check_db_changed()
+            await pilot.pause()
+
+            items = _list_items(app)
+            assert len(items) == 4
+            assert isinstance(items[2], TrackedRevisionItem)
+            assert app.query_one('#tracking-list', ListView).index == 2
+            assert app._selected_revision is not None
+            assert app._selected_revision['revision'] == 2
+
+    @pytest.mark.asyncio
+    async def test_x_is_noop_on_single_version_series(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """A series with only the tracked version has nothing to expand."""
+        _seed_db('expand-single', [SAMPLE_SERIES[1]])
+
+        app = TrackingApp('expand-single')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            assert len(_list_items(app)) == 1
+            assert not app._expanded_rows
+
+    @pytest.mark.asyncio
+    async def test_expansion_survives_limit_filter(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """Setting and clearing a limit keeps the series expanded."""
+        _seed_multiver('expand-limit')
+
+        app = TrackingApp('expand-limit')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            assert len(_list_items(app)) == 4
+
+            await pilot.press('l')
+            await pilot.pause()
+            app.screen.query_one('#limit-input', Input).value = 'multi'
+            await pilot.press('enter')
+            await pilot.pause()
+            assert len(_list_items(app)) == 4
+
+            await pilot.press('l')
+            await pilot.pause()
+            app.screen.query_one('#limit-input', Input).value = ''
+            await pilot.press('enter')
+            await pilot.pause()
+            assert len(_list_items(app)) == 4
+
+    @pytest.mark.asyncio
+    async def test_filtering_everything_away_drops_the_selection(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """An empty list must not leave a version row selected.
+
+        The empty-list branch returns above the block that re-derives the
+        selection from the cursor, so the stale pair kept every series
+        action enabled -- and on a version row 'd' skips the picker and
+        range-diffs a series the list is no longer showing.
+        """
+        _seed_multiver('expand-empty')
+
+        app = TrackingApp('expand-empty')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            await pilot.press('j')
+            await pilot.pause()
+            # Bound to a local: asserting on the attribute narrows it for
+            # the rest of the function, and mypy then calls the checks
+            # below unreachable.
+            on_child = app._selected_revision
+            assert on_child is not None
+
+            await pilot.press('l')
+            await pilot.pause()
+            app.screen.query_one('#limit-input', Input).value = 'nomatch-xyzzy'
+            await pilot.press('enter')
+            await pilot.pause()
+            assert len(app.query('#tracking-list')) == 0
+            assert app.query_one('#tracking-empty', Static)
+            assert app._selected_revision is None
+            assert app._selected_series is None
+            assert app.check_action('range_diff', ()) is False
+            # 'thread' is ungated and self-guards instead; with nothing
+            # selected it must open no screen.
+            depth = len(app.screen_stack)
+            app.action_thread()
+            await pilot.pause()
+            assert len(app.screen_stack) == depth
+
+    @pytest.mark.asyncio
+    async def test_expand_all_toggles_every_series(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """X expands all multi-version series, then collapses them."""
+        _seed_multiver('expand-all', change_id='multi-a')
+        _seed_multiver('expand-all', change_id='multi-b')
+
+        app = TrackingApp('expand-all')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            assert len(_list_items(app)) == 2
+
+            await pilot.press('X')
+            await pilot.pause()
+            assert len(_list_items(app)) == 8  # 2 parents + 3 versions each
+            assert app._expanded_rows == {('multi-a', 2), ('multi-b', 2)}
+
+            await pilot.press('X')
+            await pilot.pause()
+            assert len(_list_items(app)) == 2
+            assert not app._expanded_rows
+
+            # A partially expanded list expands the rest before collapsing
+            await pilot.press('x')
+            await pilot.pause()
+            assert len(_list_items(app)) == 5
+            await pilot.press('X')
+            await pilot.pause()
+            assert len(_list_items(app)) == 8
+
+    @pytest.mark.asyncio
+    async def test_child_msgs_column(self, tmp_path: pathlib.Path) -> None:
+        """Child counts render like the parent, with '-' when never fetched."""
+        _seed_multiver('expand-msgs')
+
+        app = TrackingApp('expand-msgs')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            items = _list_items(app)
+            # v1 was never fetched, v2 is tracked (5 messages, 2 unseen)
+            assert items[1].rev['message_count'] is None
+            assert _msgs_column(_row_text(items[1])) == '-'
+            assert _msgs_column(_row_text(items[2])) == '5 (2)'
+            assert _msgs_column(_row_text(items[0])) == '5 (2)'
+
+    def test_missing_seen_count_reads_the_same_both_ways(self) -> None:
+        """A pre-v11 row can arrive with a total but no seen count.
+
+        The Msgs column and the details panel one line below it must not
+        answer that differently.
+        """
+        assert _msgs_fields(5, None) == ('5', '', False)
+        assert '5 msgs (0 unseen)' in _format_version(
+            {'revision': 2, 'message_count': 5, 'seen_message_count': None},
+            {'revision': 3},
+        )
+
+    @pytest.mark.asyncio
+    async def test_child_rows_show_subjects(self, tmp_path: pathlib.Path) -> None:
+        """Every version row carries its own prefix-stripped title."""
+        _seed_multiver('expand-subject', revisions=[1, 2])
+        conn = tracking.get_db('expand-subject')
+        tracking.add_revision(
+            conn,
+            'multi-1',
+            3,
+            'multi-1-v3@example.com',
+            subject='[PATCH v3 0/2] multi: renamed after review',
+        )
+        conn.close()
+
+        app = TrackingApp('expand-subject')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            items = _list_items(app)
+            # Versions keeping the series title still show it -- an empty
+            # subject cell reads as missing data.
+            assert 'multi: test series' in _row_text(items[1])
+            assert 'multi: test series' in _row_text(items[2])
+            # A retitled version shows its own title, prefix-stripped.
+            assert 'multi: renamed after review' in _row_text(items[3])
+            assert '[PATCH v3' not in _row_text(items[3])
+
+    @pytest.mark.asyncio
+    async def test_details_panel_version_row(self, tmp_path: pathlib.Path) -> None:
+        """The Version row describes the highlighted child, and hides for parents."""
+        _seed_multiver('expand-details')
+
+        app = TrackingApp('expand-details')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            assert not _version_row_shown(app)
+
+            await pilot.press('x')
+            await pilot.pause()
+            await pilot.press('j')
+            await pilot.press('j')
+            await pilot.pause()
+            assert _version_row_shown(app)
+            text = _static_text(app.query_one('#detail-version', Static))
+            assert text.startswith('v2 (tracked)')
+            assert '5 msgs (2 unseen)' in text
+            assert ', posted ' in text
+
+            # A version that was never fetched has no counts to show
+            await pilot.press('k')
+            await pilot.pause()
+            text = _static_text(app.query_one('#detail-version', Static))
+            assert text.startswith('v1')
+            assert '(tracked)' not in text
+            assert '- msgs (- unseen)' in text
+
+            # The catalog knows no patch count or send date per revision, so
+            # neither is filled in from the tracked revision.
+            subj = _static_text(app.query_one('#detail-subject', Static))
+            assert subj.startswith('[v1] ')
+            assert _static_text(app.query_one('#detail-sent', Static)) == 'Unknown'
+
+            # Back on the parent row the version detail disappears
+            await pilot.press('k')
+            await pilot.pause()
+            assert not _version_row_shown(app)
+
+
+class TestChildRowActions:
+    """Tests for actions taken while a version row is highlighted."""
+
+    @pytest.mark.asyncio
+    async def test_e_opens_that_version_thread(self, tmp_path: pathlib.Path) -> None:
+        """e on a child views the child's thread, not the tracked one."""
+        _seed_multiver('child-thread')
+
+        app = TrackingApp('child-thread')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            await pilot.press('j')
+            await pilot.pause()
+
+            with patch.object(app, 'push_screen') as mock_push:
+                await pilot.press('e')
+                await pilot.pause()
+
+            assert mock_push.call_count == 1
+            screen = mock_push.call_args[0][0]
+            assert isinstance(screen, LiteThreadScreen)
+            assert screen._message_id == 'multi-1-v1@example.com'
+            assert screen._tracking_info is not None
+            assert screen._tracking_info['change_id'] == 'multi-1'
+            assert screen._tracking_info['revision'] == 1
+
+    @pytest.mark.asyncio
+    async def test_enter_opens_that_version_thread(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """Enter on a child opens the thread instead of the action menu."""
+        _seed_multiver('child-enter')
+
+        app = TrackingApp('child-enter')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            for _ in range(3):
+                await pilot.press('j')
+            await pilot.pause()
+
+            with patch.object(app, 'push_screen') as mock_push:
+                await pilot.press('enter')
+                await pilot.pause()
+
+            assert mock_push.call_count == 1
+            screen = mock_push.call_args[0][0]
+            assert isinstance(screen, LiteThreadScreen)
+            assert screen._message_id == 'multi-1-v3@example.com'
+            assert screen._tracking_info is not None
+            assert screen._tracking_info['revision'] == 3
+
+    @pytest.mark.asyncio
+    async def test_e_on_parent_still_opens_tracked_thread(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """With no child highlighted, e views the tracked revision's thread."""
+        _seed_multiver('child-parent-thread')
+
+        app = TrackingApp('child-parent-thread')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+
+            with patch.object(app, 'push_screen') as mock_push:
+                await pilot.press('e')
+                await pilot.pause()
+
+            screen = mock_push.call_args[0][0]
+            assert screen._message_id == 'multi-1-v2@example.com'
+            assert screen._tracking_info['revision'] == 2
+
+    @pytest.mark.asyncio
+    async def test_d_range_diffs_child_against_tracked(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """d on a child diffs it against the tracked revision, no picker."""
+        _seed_multiver('child-rangediff')
+
+        app = TrackingApp('child-rangediff')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            for _ in range(3):
+                await pilot.press('j')
+            await pilot.pause()
+            assert app._selected_revision is not None
+            assert app._selected_revision['revision'] == 3
+
+            with (
+                patch.object(app, '_do_range_diff') as mock_diff,
+                patch.object(app, 'suspend', return_value=contextlib.nullcontext()),
+            ):
+                await pilot.press('d')
+                await pilot.pause()
+
+            mock_diff.assert_called_once_with('multi-1', 2, 3)
+            assert not isinstance(app.screen, RangeDiffScreen)
+
+    @pytest.mark.asyncio
+    async def test_d_enabled_when_only_synthesized_row_makes_two(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """Both gates count the tracked revision the same way.
+
+        Gating d on a raw catalog count once left an expanded version row
+        with d disabled.  Tracking a series now catalogues the revision it
+        tracks, so the two counts agree by construction rather than by the
+        merge helper patching one of them up.
+        """
+        _seed_multiver('child-rangediff-gate', tracked=2, revisions=[3])
+
+        app = TrackingApp('child-rangediff-gate')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            sel = app._selected_series
+            assert sel is not None
+            assert len(sel.get('_revisions') or []) == 2
+            assert len(app._merge_tracked_revision(sel)) == 2
+            assert app.check_action('toggle_expand', ()) is True
+            assert app.check_action('range_diff', ()) is True
+
+            await pilot.press('x')
+            await pilot.pause()
+            await pilot.press('j')
+            await pilot.press('j')
+            await pilot.pause()
+            assert app._selected_revision is not None
+            assert app._selected_revision['revision'] == 3
+
+            with (
+                patch.object(app, '_do_range_diff') as mock_diff,
+                patch.object(app, 'suspend', return_value=contextlib.nullcontext()),
+            ):
+                await pilot.press('d')
+                await pilot.pause()
+            mock_diff.assert_called_once_with('multi-1', 2, 3)
+
+    @pytest.mark.asyncio
+    async def test_d_on_tracked_child_shows_picker(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """The tracked version has no implied other side — pick one."""
+        _seed_multiver('child-rangediff-self')
+
+        app = TrackingApp('child-rangediff-self')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            await pilot.press('j')
+            await pilot.press('j')
+            await pilot.pause()
+            assert app._selected_revision is not None
+            assert app._selected_revision['revision'] == 2
+
+            with patch.object(app, '_do_range_diff') as mock_diff:
+                await pilot.press('d')
+                await pilot.pause()
+                assert isinstance(app.screen, RangeDiffScreen)
+                await pilot.press('escape')
+                await pilot.pause()
+
+            mock_diff.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_series_actions_use_parent_from_child_row(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """The action menu on a child row acts on the parent series."""
+        _seed_multiver('child-action')
+
+        app = TrackingApp('child-action')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            await pilot.press('j')
+            await pilot.pause()
+
+            await pilot.press('a')
+            await pilot.pause()
+            assert isinstance(app.screen, ActionScreen)
+            lv = app.screen.query_one('#action-list', ListView)
+            actions = [c.key for c in lv.children if isinstance(c, ActionItem)]
+            assert 'abandon' in actions
+            # 'r' is greyed out on another version's row because it checks out
+            # the *tracked* revision; the menu has to refuse it too, or the
+            # checkout the guard declines is one extra keystroke away.
+            assert 'review' not in actions
+            await pilot.press('escape')
+            await pilot.pause()
+            assert app._selected_series is not None
+            assert app._selected_series['change_id'] == 'multi-1'
+
+
+class TestUnseenVersionSignal:
+    """The collapsed row has to show that a non-tracked version has mail."""
+
+    @staticmethod
+    def _set_counts(identifier: str, rev: int, total: int, seen: int) -> None:
+        conn = tracking.get_db(identifier)
+        conn.execute(
+            'UPDATE revisions SET message_count = ?, seen_message_count = ?'
+            ' WHERE change_id = ? AND revision = ?',
+            (total, seen, 'multi-1', rev),
+        )
+        conn.commit()
+        conn.close()
+
+    @pytest.mark.asyncio
+    async def test_marker_flags_unread_on_an_older_version(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        _seed_multiver('unseen-child')
+        # v1 has 3 unread; the tracked v2 has none of its own.
+        self._set_counts('unseen-child', 1, 7, 4)
+
+        app = TrackingApp('unseen-child')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            item = _list_items(app)[0]
+            assert isinstance(item, TrackedSeriesItem)
+            assert item.has_unseen_versions
+            assert '▸' in _row_text(item)
+
+    @pytest.mark.asyncio
+    async def test_tracked_revisions_own_unread_does_not_flag_it(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """The Msgs column already carries the tracked revision's badge."""
+        _seed_multiver('unseen-tracked-only')
+        # Only the tracked v2 has an unread delta (5 total, 3 seen).
+        for rev in (1, 3):
+            self._set_counts('unseen-tracked-only', rev, 4, 4)
+
+        app = TrackingApp('unseen-tracked-only')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            item = _list_items(app)[0]
+            assert isinstance(item, TrackedSeriesItem)
+            assert not item.has_unseen_versions
+
+    @pytest.mark.asyncio
+    async def test_never_counted_versions_do_not_flag_it(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """A NULL count is "unknown", not "all unread"."""
+        _seed_multiver('unseen-null')
+
+        app = TrackingApp('unseen-null')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            item = _list_items(app)[0]
+            assert isinstance(item, TrackedSeriesItem)
+            assert not item.has_unseen_versions
+
+
+class TestVersionRowDetails:
+    @pytest.mark.asyncio
+    async def test_attestation_is_not_shown_for_another_version(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """Attestation is stored per series row, so it describes v2 only."""
+        _seed_multiver('att-version')
+        conn = tracking.get_db('att-version')
+        conn.execute(
+            "UPDATE series SET attestation = 'signed:dkim/example.com'"
+            " WHERE change_id = 'multi-1'"
+        )
+        conn.commit()
+        conn.close()
+
+        app = TrackingApp('att-version')
+
+        def att_shown() -> bool:
+            return bool(app.query_one('#detail-attestation-row').display)
+
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            assert att_shown()
+
+            # Move onto a child row for a different version.
+            await pilot.press('x')
+            await pilot.pause()
+            await pilot.press('j')
+            await pilot.pause()
+            sel = _selected_rev(app)
+            assert sel is not None and sel['revision'] == 1
+            assert not att_shown()
+
+            # ...and back on the tracked version's own row it returns.
+            await pilot.press('k')
+            await pilot.pause()
+            assert att_shown()
+
+
+class TestNeedsUpdateHintSurvivesTheMergedList:
+    @pytest.mark.asyncio
+    async def test_hint_is_shown_when_the_catalog_is_empty(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """The row's '*' flag and the panel have to agree.
+
+        The merged version list always carries the tracked revision, so the
+        panel started rendering 'Revisions: v1' for a series with no catalog
+        data at all -- contradicting the '*' the same row was flying, and
+        leaving the documented "tracking data needs a refresh" hint
+        unreachable.
+        """
+        _seed_db(
+            'needs-update-hint',
+            [
+                {
+                    'change_id': 'no-cat',
+                    'revision': 1,
+                    'subject': '[PATCH] thing: do it',
+                    # Not one of the branch-backed states, or the startup
+                    # rescan marks it gone and the flag is suppressed.
+                    'status': 'accepted',
+                }
+            ],
+        )
+
+        app = TrackingApp('needs-update-hint')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            assert app._all_series[0].get('needs_update')
+            row = app.query_one('#detail-revisions-row')
+            assert row.display
+            assert 'run [u]pdate' in _static_text(
+                app.query_one('#detail-revisions', Static)
+            )
+
+    @pytest.mark.asyncio
+    async def test_hint_survives_the_v11_catalog_backfill(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """A migrated database must not lose the hint on the way in.
+
+        Schema v11 backfills a catalog row for every series row, so a
+        never-swept series stopped looking any different from one that was
+        swept and found nothing: counting catalog rows put the '*' flag and
+        the panel hint permanently out of reach for every upgrading user.
+        """
+        _seed_db(
+            'needs-update-migrated',
+            [
+                {
+                    'change_id': 'migrated',
+                    'revision': 2,
+                    'subject': '[PATCH v2] thing: do it',
+                    'status': 'accepted',
+                }
+            ],
+        )
+        conn = tracking.get_db('needs-update-migrated')
+        # Exactly what the migration leaves behind: an entry mirroring the
+        # tracked revision, and no watermark, because no sweep has run.
+        tracking.add_revision(conn, 'migrated', 2, 'migrated-v2@example.com')
+        conn.execute('UPDATE revisions SET last_update_check = NULL')
+        conn.commit()
+        conn.close()
+
+        app = TrackingApp('needs-update-migrated')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            assert app._all_series[0].get('needs_update')
+            assert 'run [u]pdate' in _static_text(
+                app.query_one('#detail-revisions', Static)
+            )
+
+    @pytest.mark.asyncio
+    async def test_no_hint_once_a_sweep_has_run(self, tmp_path: pathlib.Path) -> None:
+        """A v1 series has no other version to find, and must not nag."""
+        _seed_db(
+            'needs-update-swept',
+            [
+                {
+                    'change_id': 'swept',
+                    'revision': 1,
+                    'subject': '[PATCH] thing: do it',
+                    'status': 'accepted',
+                }
+            ],
+        )
+        conn = tracking.get_db('needs-update-swept')
+        tracking.add_revision(conn, 'swept', 1, 'swept-v1@example.com')
+        conn.execute(
+            "UPDATE revisions SET last_update_check = '2026-03-10T10:00:00+00:00'"
+        )
+        conn.commit()
+        conn.close()
+
+        app = TrackingApp('needs-update-swept')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            assert not app._all_series[0].get('needs_update')
+
+
+class TestVersionRowDates:
+    def test_panel_dates_are_local_not_a_utc_slice(self) -> None:
+        """The details panel and the version row must not disagree by a day.
+
+        The row converts to local time on purpose; the panel used to slice
+        the stored ISO string, so within the local offset of midnight the
+        two rendered different dates for the same revision.
+        """
+        old_tz = os.environ.get('TZ')
+        os.environ['TZ'] = 'Australia/Sydney'
+        time.tzset()
+        try:
+            stamp = '2026-03-12T23:30:00+00:00'
+            out = _tracking_app._format_version(
+                {
+                    'revision': 1,
+                    'message_count': 4,
+                    'seen_message_count': 4,
+                    'found_at': stamp,
+                    'last_mail_at': stamp,
+                },
+                {'revision': 2},
+            )
+            # 23:30 UTC is already the 13th in Sydney; the ISO slice says 12th.
+            assert ', posted 2026-03-13' in out
+            assert ', last activity 2026-03-13' in out
+            assert _tracking_app._local_stamp(stamp, '%d %b') == '13 Mar'
+        finally:
+            if old_tz is None:
+                os.environ.pop('TZ', None)
+            else:
+                os.environ['TZ'] = old_tz
+            time.tzset()
+
+    def test_unparseable_stamp_renders_empty(self) -> None:
+        assert _tracking_app._local_stamp('not a date', '%d %b') == ''
+        assert _tracking_app._local_stamp(None, '%d %b') == ''
+
+
+class TestDuplicateChangeIdRows:
+    """rescan_branches can leave one change_id with two live series rows."""
+
+    @staticmethod
+    def _seed(identifier: str) -> None:
+        conn = tracking.init_db(identifier)
+        for rev, added in ((2, '2026-03-01T00:00:00+00:00'), (5, '2026-03-02')):
+            conn.execute(
+                'INSERT INTO series (change_id, revision, message_id, subject,'
+                ' sender_name, sender_email, sent_at, added_at, status,'
+                " num_patches) VALUES ('dup',?,?,?,'Dee','dee@example.com',"
+                "?,?,'new',2)",
+                (
+                    rev,
+                    f'dup-v{rev}@example.com',
+                    f'[PATCH v{rev} 0/2] dup: a series',
+                    added,
+                    added,
+                ),
+            )
+        for rev in (1, 2, 5):
+            tracking.add_revision(conn, 'dup', rev, f'dup-v{rev}@example.com')
+        conn.commit()
+        conn.close()
+
+    @pytest.mark.asyncio
+    async def test_expanding_one_row_leaves_its_twin_collapsed(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """Expansion is per row, not per change_id.
+
+        Both rows are rendered separately and carry different tracked
+        revisions, so keying the expansion on the change_id alone unfolded
+        the pair together and gave each an identical set of child rows.
+        """
+        self._seed('dup-expand')
+
+        app = TrackingApp('dup-expand')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            items = _list_items(app)
+            assert len(items) == 2
+            first_rev = items[0].series['revision']
+
+            await pilot.press('x')
+            await pilot.pause()
+            items = _list_items(app)
+            # One parent grew three children; the other is untouched.
+            assert len(items) == 5
+            assert app._expanded_rows == {('dup', first_rev)}
+            parents = [i for i in items if isinstance(i, TrackedSeriesItem)]
+            assert [p.expanded for p in parents] == [True, False]
+
+    @pytest.mark.asyncio
+    async def test_unread_on_a_twins_revision_is_not_this_rows_badge(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """Another live row's revision is that row's business, not this one's.
+
+        Its counts are badged in its own Msgs column; accenting this row's
+        expander for them reports "an older version of this series has new
+        mail" about a version this series never tracked.
+        """
+        self._seed('dup-badge')
+        conn = tracking.get_db('dup-badge')
+        # Unread mail on v5 -- which the other live row tracks.
+        conn.execute(
+            'UPDATE revisions SET message_count = 9, seen_message_count = 4'
+            " WHERE change_id = 'dup' AND revision = 5"
+        )
+        conn.commit()
+        conn.close()
+
+        app = TrackingApp('dup-badge')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            by_rev = {
+                i.series['revision']: i
+                for i in _list_items(app)
+                if isinstance(i, TrackedSeriesItem)
+            }
+            # v5 is the other row's own tracked revision...
+            assert by_rev[2].has_unseen_versions is False
+            # ...and v5's row does not badge itself for it either.
+            assert by_rev[5].has_unseen_versions is False
+
+
+class TestLimitCursorRestore:
+    @pytest.mark.asyncio
+    async def test_cursor_returns_after_a_limit_matched_nothing(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """Clearing the selection must not also drop the restore hint.
+
+        The empty branch has to forget what is selected, or every action
+        stays enabled against a row the list no longer shows -- but the
+        stashed focus is only a hint, and dropping it lands the cursor at
+        the top once the filter is cleared again.
+        """
+        _seed_db('limit-empty', [SAMPLE_SERIES[0], SAMPLE_SERIES[1]])
+
+        app = TrackingApp('limit-empty')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('j')
+            await pilot.pause()
+            selected = app._selected_series
+            assert selected is not None
+            target = selected['change_id']
+
+            app._limit_pattern = 'nothing-matches-this'
+            app._stash_focus()
+            await app._refresh_list()
+            await pilot.pause()
+            while_empty = app._selected_series
+            assert while_empty is None
+
+            app._limit_pattern = ''
+            await app._refresh_list()
+            await pilot.pause()
+            restored = app._selected_series
+            assert restored is not None
+            assert restored['change_id'] == target
+
+
+class TestExpandAllScope:
+    @pytest.mark.asyncio
+    async def test_expand_all_ignores_filtered_out_series(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """[X] judged "already expanded?" against rows the filter hides."""
+        _seed_multiver('expand-scope', change_id='multi-1')
+        conn = tracking.get_db('expand-scope')
+        tracking.add_series_to_db(
+            conn,
+            change_id='other-1',
+            revision=2,
+            subject='[PATCH v2 0/2] other: hidden series',
+            sender_name='Hidden Hank',
+            sender_email='hank@example.com',
+            sent_at='2026-03-11T10:00:00+00:00',
+            message_id='other-1-v2@example.com',
+            num_patches=2,
+        )
+        for rev in (1, 2):
+            tracking.add_revision(conn, 'other-1', rev, f'other-1-v{rev}@example.com')
+        conn.close()
+
+        app = TrackingApp('expand-scope')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            # Hide 'other-1' behind the limit filter.
+            app._limit_pattern = 'multi'
+            await app._refresh_list()
+            await pilot.pause()
+            assert len(_list_items(app)) == 1
+
+            app.action_expand_all()
+            await pilot.pause()
+            # The visible series expanded, and the hidden one was not
+            # counted when deciding expand-vs-collapse.
+            assert app._expanded_rows == {('multi-1', 2)}
+            assert len(_list_items(app)) == 4
+
+    @pytest.mark.asyncio
+    async def test_expand_all_collapse_leaves_hidden_series_alone(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """[X] collapsing must not fold up what the filter is hiding."""
+        _seed_multiver('collapse-scope', change_id='multi-1')
+        conn = tracking.get_db('collapse-scope')
+        tracking.add_series_to_db(
+            conn,
+            change_id='other-1',
+            revision=2,
+            subject='[PATCH v2 0/2] other: hidden series',
+            sender_name='Hidden Hank',
+            sender_email='hank@example.com',
+            sent_at='2026-03-11T10:00:00+00:00',
+            message_id='other-1-v2@example.com',
+            num_patches=2,
+        )
+        for rev in (1, 2):
+            tracking.add_revision(conn, 'other-1', rev, f'other-1-v{rev}@example.com')
+        conn.close()
+
+        app = TrackingApp('collapse-scope')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            # Expand both, then hide one behind the limit filter.
+            app.action_expand_all()
+            await pilot.pause()
+            assert app._expanded_rows == {('multi-1', 2), ('other-1', 2)}
+            app._limit_pattern = 'multi'
+            await app._refresh_list()
+            await pilot.pause()
+
+            app.action_expand_all()
+            await pilot.pause()
+            # Only the displayed series collapsed.
+            assert app._expanded_rows == {('other-1', 2)}
+
+
+class TestUnseenVersionMarkerRendering:
+    """The computed flag has to reach the glyph, not just the attribute."""
+
+    @pytest.mark.asyncio
+    async def test_the_marker_is_accented_when_an_older_version_has_mail(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        _seed_multiver('unseen-style')
+        conn = tracking.get_db('unseen-style')
+        conn.execute(
+            'UPDATE revisions SET message_count = 7, seen_message_count = 4'
+            " WHERE change_id = 'multi-1' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+
+        app = TrackingApp('unseen-style')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            label = _list_items(app)[0].query_one(Label)
+            marker_spans = [
+                span
+                for span in label.content.spans
+                if label.content.plain[span.start : span.end] == '▸'
+            ]
+            assert marker_spans, 'the ▸ marker carries no style'
+            assert 'bold' in str(marker_spans[0].style)
+
+    @pytest.mark.asyncio
+    async def test_the_marker_is_plain_when_nothing_is_unread(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        _seed_multiver('unseen-style-none')
+
+        app = TrackingApp('unseen-style-none')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            label = _list_items(app)[0].query_one(Label)
+            marker_spans = [
+                span
+                for span in label.content.spans
+                if label.content.plain[span.start : span.end] == '▸'
+            ]
+            assert marker_spans == []
+
+
+class TestStatusActionsKeepTheVersionRow:
+    @pytest.mark.asyncio
+    async def test_waiting_from_a_version_row_stays_there(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """A status change says nothing about which versions the series has.
+
+        Setting a target branch already left the cursor alone; snooze,
+        unsnooze, waiting and thank bounced it up to the parent, so two
+        status-only actions taken from the same row disagreed about where
+        the cursor belonged afterwards.
+        """
+        _seed_multiver('status-keeps-row')
+        app = TrackingApp('status-keeps-row')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            await pilot.press('j')  # onto the first version row
+            await pilot.pause()
+            before = app.query_one('#tracking-list', ListView).index
+            assert isinstance(_list_items(app)[before], TrackedRevisionItem)
+            picked = _list_items(app)[before].rev['revision']
+
+            app.action_waiting()
+            await pilot.pause()
+            await pilot.pause()
+
+            item = _list_items(app)[
+                app.query_one('#tracking-list', ListView).index or 0
+            ]
+            assert isinstance(item, TrackedRevisionItem)
+            assert item.rev['revision'] == picked
+            assert app._selected_revision is not None
+            assert app._selected_revision['revision'] == picked
+
+
+class TestVersionRowDateIsLocal:
+    @pytest.mark.asyncio
+    async def test_the_date_is_rendered_in_local_time(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """The column stores UTC; showing it raw puts a row a day out.
+
+        23:00 UTC is the next day in any eastward zone, which is exactly
+        when a maintainer notices the version row and the thread viewer
+        disagreeing about the same message.
+
+        Plain environ save/restore, not monkeypatch.setenv: the fixture
+        undoes its env change after this method's finally block, so the
+        last tzset() here would run with TZ unset and leave libc's cached
+        zone disagreeing with os.environ for the rest of the process.
+        """
+        old_tz = os.environ.get('TZ')
+        os.environ['TZ'] = 'Australia/Sydney'
+        time.tzset()
+        try:
+            _seed_multiver('rowdate-local')
+            conn = tracking.get_db('rowdate-local')
+            conn.execute(
+                "UPDATE revisions SET last_mail_at = '2026-03-11T23:30:00+00:00'"
+                " WHERE change_id = 'multi-1' AND revision = 1"
+            )
+            conn.commit()
+            conn.close()
+
+            app = TrackingApp('rowdate-local')
+            async with app.run_test(size=(120, 30)) as pilot:
+                await pilot.pause()
+                await pilot.press('x')
+                await pilot.pause()
+                child = _list_items(app)[1]
+                assert isinstance(child, TrackedRevisionItem)
+                assert '12 Mar' in _row_text(child)
+        finally:
+            if old_tz is None:
+                os.environ.pop('TZ', None)
+            else:
+                os.environ['TZ'] = old_tz
+            time.tzset()
+
+    @pytest.mark.asyncio
+    async def test_the_date_touches_neither_neighbour(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """The date shares the submitter field with the version label.
+
+        Both are variable width, so the check is that blanks separate the
+        date from the version beside it and from the counts after it, and
+        that Subject still starts on the same column as the parent row's --
+        which is the column the header describes.
+        """
+        _seed_multiver('rowdate-gap')
+        conn = tracking.get_db('rowdate-gap')
+        conn.execute(
+            "UPDATE revisions SET last_mail_at = '2026-03-11T10:00:00+00:00'"
+            " WHERE change_id = 'multi-1' AND revision = 1"
+        )
+        conn.commit()
+        conn.close()
+
+        app = TrackingApp('rowdate-gap')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            items = _list_items(app)
+            child = items[1]
+            assert isinstance(child, TrackedRevisionItem)
+            text = _row_text(child)
+            assert 'multi: test series' in text
+            # Blank on both sides of the date, whatever it renders as.
+            assert re.search(r'v1 +11 Mar +', text)
+            # Subject starts where the parent's does, and the header says so.
+            assert text.index('multi: test series') == _row_text(items[0]).index('[v2,')
+
+
+class TestConflictNoticeKeepsItsKeyHint:
+    def test_the_link_key_survives_markup_rendering(self) -> None:
+        """notify() renders Rich markup, so a bare [l] is eaten as a tag."""
+        from textual.content import Content
+
+        from b4.review_tui._tracking_app import _conflicts_notice
+
+        notice = _conflicts_notice([2, 3])
+        rendered = Content.from_markup(notice).plain
+        assert 'v2, v3' in rendered
+        assert '[l]' in rendered
+        # ...and nothing was mistaken for a style along the way.
+        assert Content.from_markup(notice).spans == []
+
+
+class TestDiscoveryErrorSurvivesMarkupRendering:
+    """A lore exception is arbitrary text, and notify() parses markup."""
+
+    def test_a_bracketed_subject_is_not_swallowed(self) -> None:
+        """Lowercase-initial brackets parse as a style tag and vanish."""
+        from textual.content import Content
+
+        from b4.review_tui._tracking_app import _discovery_error_notice
+
+        notice = _discovery_error_notice('no match for [patch v2 1/3] foo: fix')
+        rendered = Content.from_markup(notice).plain
+        assert '[patch v2 1/3] foo: fix' in rendered
+        assert Content.from_markup(notice).spans == []
+
+    def test_a_closing_tag_does_not_raise(self) -> None:
+        """An unbalanced '[/...]' raises MarkupError inside the toast."""
+        from textual.content import Content
+
+        from b4.review_tui._tracking_app import _discovery_error_notice
+
+        notice = _discovery_error_notice('cannot read [/var/tmp/x] while fetching')
+        rendered = Content.from_markup(notice).plain
+        assert '[/var/tmp/x]' in rendered
+
+
+class TestVersionRowActionGating:
+    @pytest.mark.asyncio
+    async def test_range_diff_is_offered_on_a_thanked_series(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """[x] expands in every state, and the docs promise 'd' on a child."""
+        _seed_multiver('gate-thanked')
+        conn = tracking.get_db('gate-thanked')
+        tracking.update_series_status(conn, 'multi-1', 'thanked', revision=2)
+        conn.close()
+
+        app = TrackingApp('gate-thanked')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            assert app.check_action('toggle_expand', ()) is True
+            assert app.check_action('range_diff', ()) is True
+
+    @pytest.mark.asyncio
+    async def test_review_is_disabled_on_another_version(
+        self, tmp_path: pathlib.Path
+    ) -> None:
+        """'r' checks out the tracked revision, not the highlighted one."""
+        _seed_multiver('gate-review')
+        app = TrackingApp('gate-review')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            assert app.check_action('review', ()) is True
+            await pilot.press('x')
+            await pilot.pause()
+            # Cursor onto v1 -- not the tracked v2.
+            await pilot.press('j')
+            await pilot.pause()
+            assert _selected_rev(app) is not None
+            assert app.check_action('review', ()) is False
+            # ...and on the tracked revision's own row it is fine again.
+            await pilot.press('j')
+            await pilot.pause()
+            assert _selected_rev(app) is not None
+            assert app.check_action('review', ()) is True
+
+    @pytest.mark.asyncio
+    async def test_take_and_rebase_are_disabled_on_another_version(
+        self, gitdir: str
+    ) -> None:
+        """Both act on the review branch, which holds the tracked revision.
+
+        'r' was greyed out for that reason from the start; these two build
+        and move the very same branch, so offering them on a v1 row runs
+        them against v2 while every label on screen says v1.
+        """
+        # A real branch, or the startup rescan turns 'reviewing' into 'gone'
+        # and neither action is offered in any case.
+        _create_review_branch(gitdir, 'multi-1', identifier='gate-take', revision=2)
+        _seed_multiver('gate-take')
+        conn = tracking.get_db('gate-take')
+        tracking.update_series_status(conn, 'multi-1', 'reviewing', revision=2)
+        conn.close()
+
+        app = TrackingApp('gate-take')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            assert app.check_action('take', ()) is True
+            assert app.check_action('rebase', ()) is True
+            await pilot.press('x')
+            await pilot.pause()
+            # Cursor onto v1 -- not the tracked v2.
+            await pilot.press('j')
+            await pilot.pause()
+            assert _selected_rev(app) is not None
+            assert app.check_action('take', ()) is False
+            assert app.check_action('rebase', ()) is False
+            # ...and the action menu drops them for the same row.
+            await pilot.press('a')
+            await pilot.pause()
+            assert isinstance(app.screen, ActionScreen)
+            lv = app.screen.query_one('#action-list', ListView)
+            from b4.review_tui._modals import ActionItem
+
+            offered = [c.key for c in lv.children if isinstance(c, ActionItem)]
+            assert 'take' not in offered
+            assert 'rebase' not in offered
+            # An action that does not touch the branch is still there.
+            assert 'snooze' in offered
+            await pilot.press('escape')
+
+
+class TestThreadViewLeavesNoStaleFocus:
+    """Viewing a version's thread must not capture a later reload.
+
+    _stash_focus() is a hint for the *next* _refresh_list(), and every
+    other caller reloads immediately.  action_thread() instead pushes a
+    screen and returns, so the hint outlives the action -- and re-reading
+    an already-read thread writes nothing, leaving the DB mtime alone and
+    _check_db_changed() asleep, so nothing consumes it either.  The next
+    reload from an unrelated action then parks the cursor on the version
+    row the maintainer looked at, arming _selected_revision behind their
+    back.
+    """
+
+    @pytest.mark.asyncio
+    async def test_a_viewed_version_does_not_capture_a_later_reload(
+        self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        from textual.screen import ModalScreen
+
+        class _StubThreadScreen(ModalScreen[None]):
+            """Stands in for LiteThreadScreen: pushes and pops, no fetch."""
+
+            def __init__(self, *args: Any, **kwargs: Any) -> None:
+                super().__init__()
+
+        monkeypatch.setattr(
+            'b4.review_tui._lite_app.LiteThreadScreen', _StubThreadScreen
+        )
+        # Re-reading a thread whose counts have not moved writes nothing, so
+        # in the real flow the mtime poller stays asleep and never reloads.
+        # The startup rescan does touch the DB here, so pin the poller off
+        # rather than race its one-second timer.
+        monkeypatch.setattr(TrackingApp, '_check_db_changed', lambda self: None)
+
+        # Seeded oldest-first: the list sorts newest-tracked first, so
+        # 'multi-b' heads the list and 'multi-a' follows it.
+        _seed_multiver('thread-focus', change_id='multi-a')
+        _seed_multiver('thread-focus', change_id='multi-b')
+
+        app = TrackingApp('thread-focus')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            items = _list_items(app)
+            assert [i.series['change_id'] for i in items] == ['multi-b', 'multi-a']
+
+            # Expand 'multi-a' and put the cursor on its v1 row.
+            await pilot.press('j')
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            await pilot.press('j')
+            await pilot.pause()
+            on_child = app._selected_revision
+            assert on_child is not None
+            assert on_child['revision'] == 1
+
+            # View that version's thread and come back.  Nothing about the
+            # thread changed, so no reload happens on the way out.
+            depth = len(app.screen_stack)
+            app.action_thread()
+            await pilot.pause()
+            assert len(app.screen_stack) == depth + 1
+            app.pop_screen()
+            await pilot.pause()
+
+            # The hint must not outlive the action that set it.
+            assert app._focus_change_id is None
+            assert app._focus_revision is None
+
+            # Navigate back up to 'multi-b' and abandon it -- a reload that
+            # deliberately does not stash a focus of its own.
+            await pilot.press('k')
+            await pilot.press('k')
+            await pilot.pause()
+            selected = app._selected_series
+            assert selected is not None
+            assert selected['change_id'] == 'multi-b'
+
+            app._on_abandon_confirmed(True, 'multi-b', 'b4/review/multi-b', False)
+            await pilot.pause()
+
+            # The cursor lands on a series row, not on the version row that
+            # was looked at three actions ago.
+            lv = app.query_one('#tracking-list', ListView)
+            landed = lv.highlighted_child
+            assert isinstance(landed, TrackedSeriesItem)
+            assert app._selected_revision is None
+
+
+class TestExpandAllWithoutASelection:
+    """[X] needs no selection, so it must not restore by row index.
+
+    _stash_focus() records a hint only when something is selected, and
+    expanding is precisely the operation that inserts rows above the
+    cursor -- so the index fallback lands somewhere unrelated.
+    """
+
+    @pytest.mark.asyncio
+    async def test_expand_all_keeps_the_highlighted_series(
+        self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        # The mtime poller re-stashes and reloads on its own timer; pin it
+        # off so this exercises [X]'s restore and not a race with it.
+        monkeypatch.setattr(TrackingApp, '_check_db_changed', lambda self: None)
+
+        # Display order is newest-tracked first: multi-c, multi-b, multi-a.
+        for change_id in ('multi-a', 'multi-b', 'multi-c'):
+            _seed_multiver('expand-nosel', change_id=change_id)
+
+        app = TrackingApp('expand-nosel')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            assert [i.series['change_id'] for i in _list_items(app)] == [
+                'multi-c',
+                'multi-b',
+                'multi-a',
+            ]
+
+            # Put the cursor on the last row, away from index 0.
+            await pilot.press('j')
+            await pilot.press('j')
+            await pilot.pause()
+            selected = app._selected_series
+            assert selected is not None
+            target = selected['change_id']
+            assert target == 'multi-a'
+
+            # Close the details panel: the selection goes, the cursor stays.
+            await pilot.press('escape')
+            await pilot.pause()
+            assert app._selected_series is None
+
+            await pilot.press('X')
+            await pilot.pause()
+            assert len(_list_items(app)) == 12  # 3 parents + 3 versions each
+
+            # Still the same series, and still its parent row.
+            lv = app.query_one('#tracking-list', ListView)
+            landed = lv.highlighted_child
+            assert isinstance(landed, TrackedSeriesItem)
+            assert landed.series['change_id'] == target
+            assert app._selected_revision is None
+
+
+class TestTargetBranchKeepsTheVersionRow:
+    """Setting a target branch must not repaint the panel for another row.
+
+    [t] acts on the series whichever row the cursor is on, which is fine
+    -- but its callback refreshes the details panel without saying which
+    version the cursor is sitting on, so the panel silently reverts to
+    the tracked revision while the cursor visibly stays on the child row.
+    """
+
+    @pytest.mark.asyncio
+    async def test_setting_a_target_keeps_the_panel_on_the_version_row(
+        self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        # The mtime poller would reload and repaint on its own timer; pin
+        # it off so this pins the callback's own behaviour.
+        monkeypatch.setattr(TrackingApp, '_check_db_changed', lambda self: None)
+        _seed_multiver('target-version-row')
+
+        app = TrackingApp('target-version-row')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            await pilot.press('x')
+            await pilot.pause()
+            await pilot.press('j')
+            await pilot.pause()
+            on_child = app._selected_revision
+            assert on_child is not None
+            assert on_child['revision'] == 1
+            assert _version_row_shown(app)
+
+            app._on_target_branch_set('sound/for-next')
+            await pilot.pause()
+
+            # The cursor never moved, so the panel must still describe the
+            # version it is on.
+            still_on = app._selected_revision
+            assert still_on is not None
+            assert still_on['revision'] == 1
+            assert _version_row_shown(app)

-- 
2.53.0


^ permalink raw reply related	[flat|nested] 26+ messages in thread

end of thread, other threads:[~2026-08-12 21:47 UTC | newest]

Thread overview: 26+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-08-12 21:46 [PATCH RFC v2 00/25] review: track and browse every version of a tracked series Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 01/25] review-tui: fix rethreaded series thread viewing Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 02/25] review: do not clear fields a re-adding caller does not know Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 03/25] review-tui: keep the rethread flag on an upgraded series row Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 04/25] review: guard the tracking-commit amend on the worktree, not the checkout Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 05/25] review-tui: recompute an evicted A·R·T cache entry Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 06/25] review: test the prerequisite fixes Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 07/25] review: serialize schema migrations against a concurrent opener Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 08/25] review: test the migration serialization Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 09/25] review: track message counts for all revisions of a series Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 10/25] review: give per-change_id state its own table Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 11/25] review: test per-revision message tracking Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 12/25] review-tui: poll every revision on u/U updates Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 13/25] review: test the per-revision poll sweep Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 14/25] review-tui: resolve the tracked revision in revision lists Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 15/25] review-tui: fall back when a cached thread blob has no series Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 16/25] review-tui: test revision resolution and the range-diff fallback Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 17/25] review: skip the catalog mirror when nothing moved Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 18/25] review: match a stray posting by message-id Christian Brauner
2026-08-12 21:47 ` [PATCH RFC v2 19/25] review: add backward discovery of older series revisions Christian Brauner
2026-08-12 21:47 ` [PATCH RFC v2 20/25] review-tui: add a "Find older revisions" action Christian Brauner
2026-08-12 21:47 ` [PATCH RFC v2 21/25] review: test the catalog mirror, stray matching and backward discovery Christian Brauner
2026-08-12 21:47 ` [PATCH RFC v2 22/25] review-tui: extract the Msgs column renderer from TrackedSeriesItem Christian Brauner
2026-08-12 21:47 ` [PATCH RFC v2 23/25] review-tui: give the unseen badge a column of its own Christian Brauner
2026-08-12 21:47 ` [PATCH RFC v2 24/25] review-tui: expand tracked series into per-version rows Christian Brauner
2026-08-12 21:47 ` [PATCH RFC v2 25/25] review-tui: test per-version tracker rows Christian Brauner

This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.