Merge nucleic/amber-thistle-dingo-urcn into dev

This commit is contained in:
2026-07-20 18:08:22 -07:00
parent 540101e7ac
commit 1e598480e1
2 changed files with 98 additions and 2 deletions
@@ -93,6 +93,11 @@ final class HostConnection {
/// (the one callback that *shrinks* the on-screen transcript). Carries the sessionID so /// (the one callback that *shrinks* the on-screen transcript). Carries the sessionID so
/// RemoteStore can confirm it's still the open one before mutating. /// RemoteStore can confirm it's still the open one before mutating.
var openReverted: (SessionID, UInt64) -> Void = { _, _ in } 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 /// A background transcript prefetch finished — everything it fetched (empty when the
/// host had nothing / the fetch died with the connection). RemoteStore merges it into /// host had nothing / the fetch died with the connection). RemoteStore merges it into
/// the offline cache and starts the next queued prefetch either way. /// the offline cache and starts the next queued prefetch either way.
@@ -794,6 +799,9 @@ final class HostConnection {
break break
case .transcriptReverted(let reverted): case .transcriptReverted(let reverted):
// The owner reverted/undid this chat — the transcript was truncated to `throughSeq`. // 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 { if reverted.sessionID == openSessionID {
// On screen: drop our cursor/dedup state for the dropped seqs (so a warm-resubscribe // 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 // replays from the new tip, not the stale one) and have RemoteStore shrink the live
@@ -1498,6 +1498,18 @@ final class RemoteStore: ObservableObject {
self.persistOpenTranscript() 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 cb.transcriptPrefetched = { [weak self] sessionID, events in
guard let self else { return } guard let self else { return }
self.prefetchInFlight = nil self.prefetchInFlight = nil
@@ -1644,6 +1656,13 @@ final class RemoteStore: ObservableObject {
let live = aggregatedSessions() let live = aggregatedSessions()
if !live.isEmpty { if !live.isEmpty {
if sessions != live { 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 sessions = live
cachedSummaries = live cachedSummaries = live
persistSummaries(live) persistSummaries(live)
@@ -1720,6 +1739,66 @@ final class RemoteStore: ObservableObject {
// MARK: - Offline cache (SessionCache) // 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 /// Debounced write of the live session list to disk, so a read-only history survives a
/// disconnect / relaunch. /// disconnect / relaunch.
private func persistSummaries(_ list: [WireSessionSummary]) { private func persistSummaries(_ list: [WireSessionSummary]) {
@@ -1789,13 +1868,22 @@ final class RemoteStore: ObservableObject {
Task { [weak self] in Task { [weak self] in
// Pull only the gap beyond what's cached; a cold session is bounded to the same // Pull only the gap beyond what's cached; a cold session is bounded to the same
// tail window the cache would keep anyway. // 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 } guard let self else { return }
if cachedLast >= summary.lastSeq { if cachedLast == summary.lastSeq {
self.prefetchInFlight = nil // cache already current self.prefetchInFlight = nil // cache already current
self.pumpTranscriptPrefetch() self.pumpTranscriptPrefetch()
return 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) let coldFloor = summary.lastSeq > UInt64(SessionCache.eventLimit)
? summary.lastSeq - UInt64(SessionCache.eventLimit) : 0 ? summary.lastSeq - UInt64(SessionCache.eventLimit) : 0
let afterSeq = cachedLast > 0 ? cachedLast : coldFloor let afterSeq = cachedLast > 0 ? cachedLast : coldFloor