From 1e598480e167850a36111cc42d6db32d44f4eeba Mon Sep 17 00:00:00 2001 From: Nucleic Date: Mon, 20 Jul 2026 18:08:22 -0700 Subject: [PATCH] Merge nucleic/amber-thistle-dingo-urcn into dev --- .../NucleicRemote/Models/HostConnection.swift | 8 ++ .../NucleicRemote/Models/RemoteStore.swift | 92 ++++++++++++++++++- 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index ff4a60a..762998e 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -93,6 +93,11 @@ final class HostConnection { /// (the one callback that *shrinks* the on-screen transcript). Carries the sessionID so /// RemoteStore can confirm it's still the open one before mutating. var openReverted: (SessionID, UInt64) -> Void = { _, _ in } + /// Any session was reverted on the owner (open or not) — fired alongside the truncation + /// handling so RemoteStore can record the owner's post-revert epoch as applied. That + /// record is what stops the summary-level missed-revert heal from re-dropping a cache + /// this live message already truncated precisely. + var transcriptReverted: (TranscriptReverted) -> Void = { _ in } /// A background transcript prefetch finished — everything it fetched (empty when the /// host had nothing / the fetch died with the connection). RemoteStore merges it into /// the offline cache and starts the next queued prefetch either way. @@ -794,6 +799,9 @@ final class HostConnection { break case .transcriptReverted(let reverted): // The owner reverted/undid this chat — the transcript was truncated to `throughSeq`. + // Tell RemoteStore first (it records the applied revert epoch so the summary-level + // missed-revert heal knows this device converged via this precise path). + callbacks.transcriptReverted(reverted) if reverted.sessionID == openSessionID { // On screen: drop our cursor/dedup state for the dropped seqs (so a warm-resubscribe // replays from the new tip, not the stale one) and have RemoteStore shrink the live diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index e849115..74bc5aa 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -1498,6 +1498,18 @@ final class RemoteStore: ObservableObject { self.persistOpenTranscript() } } + cb.transcriptReverted = { [weak self] reverted in + guard let self else { return } + // The transcript shrank — clear the prefetch watermark so the cache re-warms once + // the owner regrows, instead of waiting for the tip to pass its pre-revert high. + self.prefetchedSeq[reverted.sessionID] = nil + guard let epoch = reverted.revertEpoch else { return } + // The live revert path (openReverted / HostConnection's cache truncate) converges + // this device precisely. Record the epoch it converged to so the summary-level + // missed-revert heal (`healMissedReverts`) recognizes the bumped epoch in the next + // session list as already applied instead of re-dropping the whole cache. + self.appliedRevertEpochs[reverted.sessionID] = epoch + } cb.transcriptPrefetched = { [weak self] sessionID, events in guard let self else { return } self.prefetchInFlight = nil @@ -1644,6 +1656,13 @@ final class RemoteStore: ObservableObject { let live = aggregatedSessions() if !live.isEmpty { if sessions != live { + // Before adopting the fresh list, compare it against the last summaries we held + // (seeded from disk on a cold launch): a session whose revert epoch moved while + // we weren't receiving the live `.transcriptReverted` — offline across a revert — + // has a cache that may hold pre-revert content at reused seqs, which every merge + // path here is too grow-only to ever fix. Heal it now, before the stale summaries + // are overwritten. + healMissedReverts(previous: cachedSummaries, incoming: live) sessions = live cachedSummaries = live persistSummaries(live) @@ -1720,6 +1739,66 @@ final class RemoteStore: ObservableObject { // MARK: - Offline cache (SessionCache) + /// Revert epochs already applied via the live `.transcriptReverted` path, per session — so + /// `healMissedReverts` can tell a revert this device truncated precisely (connected) from one + /// it slept through (offline), and only nukes the cache for the latter. In-memory only: a + /// relaunch that lost this map at worst re-drops a cache the prefetcher re-warms. + private var appliedRevertEpochs: [SessionID: UInt64] = [:] + + /// Detect reverts this device missed and drop what they invalidated. A revert on the owner + /// resets the transcript's seq counter, so post-revert turns reuse dropped seqs with + /// *different* content; every merge here (seed, snapshot, live, backfill, prefetch) dedupes + /// on seq and only grows — a phone offline across the revert would keep the stale events + /// forever. The owner's `SessionSummary.revertEpoch` moving against the summary we last held + /// is the tip-independent signal: drop the cached transcript, requalify the prefetch, and — + /// if the session is on screen — rebuild the open transcript from the host cold. A legacy + /// host (no epoch) still gets the tip-regression heal: a `lastSeq` below what we held means + /// at least the tail is gone, so shrink the cache to it. + private func healMissedReverts(previous: [WireSessionSummary], incoming: [WireSessionSummary]) { + guard !previous.isEmpty else { return } + let prevByID = Dictionary(previous.map { ($0.sessionID, $0) }, uniquingKeysWith: { a, _ in a }) + for summary in incoming { + guard let prev = prevByID[summary.sessionID] else { continue } + if let epoch = summary.revertEpoch, + appliedRevertEpochs[summary.sessionID] != epoch, + prev.revertEpoch.map({ $0 != epoch }) ?? (epoch > 0) { + // Missed revert (or a pre-epoch cached summary we can't vouch for on an + // ever-reverted session): the cache may hold another generation's content. + appliedRevertEpochs[summary.sessionID] = epoch + SessionCache.removeEvents(for: summary.sessionID) + prefetchedSeq[summary.sessionID] = nil + if summary.sessionID == openSessionID { + resubscribeOpenSessionCold(summary.sessionID) + } + } else if summary.revertEpoch == nil, summary.lastSeq < prev.lastSeq { + // Legacy host: no epoch to compare, but the tip regressed below what we held — + // drop at least the now-dropped tail (reused seqs below the new tip are not + // detectable without epochs). + SessionCache.truncateEvents(for: summary.sessionID, throughSeq: summary.lastSeq) + prefetchedSeq[summary.sessionID] = nil + if summary.sessionID == openSessionID { + drainPendingOpenEvents() + openEvents = openEvents.filter { $0.seq <= summary.lastSeq } + } + } + } + } + + /// Rebuild the open session's transcript from the host, cold — after a missed revert made + /// everything we hold for it (screen, buffers, connection cursors, cache) untrustworthy. + /// Mirrors `open()`'s reset without touching navigation: clear the view state, reset the + /// owning connection's per-session cursor/dedup/fetch state (the `openSessionID` didSet), + /// then re-subscribe with no cursor and re-pull the full history. + private func resubscribeOpenSessionCold(_ sessionID: SessionID) { + discardPendingOpenEvents() + openEvents = [] + guard let conn = connection(owningSession: sessionID) else { return } + conn.openSessionID = nil + conn.openSessionID = sessionID + conn.send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full))) + conn.fetchFullTranscript(sessionID) + } + /// Debounced write of the live session list to disk, so a read-only history survives a /// disconnect / relaunch. private func persistSummaries(_ list: [WireSessionSummary]) { @@ -1789,13 +1868,22 @@ final class RemoteStore: ObservableObject { Task { [weak self] in // Pull only the gap beyond what's cached; a cold session is bounded to the same // tail window the cache would keep anyway. - let cachedLast = await SessionCache.loadEvents(id).last?.seq ?? 0 + var cachedLast = await SessionCache.loadEvents(id).last?.seq ?? 0 guard let self else { return } - if cachedLast >= summary.lastSeq { + if cachedLast == summary.lastSeq { self.prefetchInFlight = nil // cache already current self.pumpTranscriptPrefetch() return } + if cachedLast > summary.lastSeq { + // The host's tip is *below* our cache — a revert we missed. This is not "already + // current": the host reuses the dropped seqs with different content, so the whole + // cached copy is suspect (a legacy host without revert epochs never triggers the + // epoch heal, and reading "ahead of the host" as current was what let the stale + // copy live forever). Drop it and re-pull from scratch. + SessionCache.removeEvents(for: id) + cachedLast = 0 + } let coldFloor = summary.lastSeq > UInt64(SessionCache.eventLimit) ? summary.lastSeq - UInt64(SessionCache.eventLimit) : 0 let afterSeq = cachedLast > 0 ? cachedLast : coldFloor