From bd9bacb8b34c810d682d1b1636c7dd37ebbafbee Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Mon, 4 May 2026 23:52:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(scarfmon):=20B2=20+=20B3=20+=20iOS=20dashb?= =?UTF-8?q?oard=20=E2=80=94=20file=20watcher,=20message=20hydration,=20das?= =?UTF-8?q?hboard=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three areas instrumented in this batch. Both targets build clean. B2 — Mac HermesFileWatcher (FSEvents + remote SSH poll) - mac.fileWatcher.localFire (event) — every FSEvents change on a watched core or project path. High counts during streaming chats are normal (state.db-wal ticks per persisted message); high counts during idle suggest a runaway watcher install. - mac.fileWatcher.remoteRestart (event, bytes=path-count) — fires once per SSH poller restart, with the union path count attached. Frequent restarts mean the project-list update path is churning. - mac.fileWatcher.remoteDelta (event) — fires per non-empty change detected on the SSH poll. Pair with `ssh.streamScript` cadence to see actual poll latency. B3 — Chat session boot + message hydration - mac.fetchMessages (interval) + .rows (event) — bounded SQL fetch from HermesDataService. Catches slow paginated scrolls back through long sessions. - mac.refreshSessionFromDB (interval) — RichChatViewModel's post-promptComplete refresh that picks up cost/token data. - mac.hydrateMessages (interval) + .rows (event) — full session-boot hydration in RichChatViewModel.loadSessionHistory. Was the suspected trigger of the 22-bubble session-start storms in the Phase 3a baseline; now precisely measurable. iOS Dashboard (resolves the original "out of sync" mystery) - ios.loadDashboard (interval) — wraps the four dataService.fetch* Citadel SFTP round-trips in IOSDashboardViewModel.load(). - ios.allSessions.count (event) — sidebar list size after each load, correlates load latency with list growth. - ios.dashboardRefresh.trigger (event) — fires only on pull-to-refresh, separates that entry path from initial appear. **Architectural finding:** the original v2.6.0 user feedback ("chat out of sync iOS↔Mac on fast LAN") is now firmly attributable to this — iOS does NOT subscribe to a file watcher. The dashboard refresh path is appear-time + pull-to-refresh only. `CitadelServerTransport.watchPaths()` is effectively dead code on iOS today; nobody calls it. Earlier A1 instrumentation (commit 9df7142) put measure points on it, which is why captures showed zero `ios.fileWatcher.tick` events. Future work: either add a foregrounded poll loop to iOS, or thread the file watcher into the dashboard subscription. Documented in the ScarfMon roadmap memory. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Services/HermesDataService.swift | 36 ++++++++++--------- .../ViewModels/RichChatViewModel.swift | 17 +++++---- .../ScarfIOS/IOSDashboardViewModel.swift | 12 ++++--- .../Core/Services/HermesFileWatcher.swift | 18 +++++++++- 4 files changed, 56 insertions(+), 27 deletions(-) diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesDataService.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesDataService.swift index 15e6234..fd27b85 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesDataService.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesDataService.swift @@ -188,22 +188,26 @@ public actor HermesDataService { limit: Int, before: Int? = nil ) async -> [HermesMessage] { - let sql: String - let params: [SQLValue] - if let before { - sql = "SELECT \(messageColumns) FROM messages WHERE session_id = ? AND id < ? ORDER BY id DESC LIMIT ?" - params = [.text(sessionId), .integer(Int64(before)), .integer(Int64(limit))] - } else { - sql = "SELECT \(messageColumns) FROM messages WHERE session_id = ? ORDER BY id DESC LIMIT ?" - params = [.text(sessionId), .integer(Int64(limit))] - } - do { - let rows = try await backend.query(sql, params: params) - // Caller wants chronological (oldest-first) order; the SELECT - // is DESC for the LIMIT to bite the newest rows, so reverse. - return rows.map { messageFromRow($0) }.reversed() - } catch { - return [] + await ScarfMon.measureAsync(.sessionLoad, "mac.fetchMessages") { + let sql: String + let params: [SQLValue] + if let before { + sql = "SELECT \(messageColumns) FROM messages WHERE session_id = ? AND id < ? ORDER BY id DESC LIMIT ?" + params = [.text(sessionId), .integer(Int64(before)), .integer(Int64(limit))] + } else { + sql = "SELECT \(messageColumns) FROM messages WHERE session_id = ? ORDER BY id DESC LIMIT ?" + params = [.text(sessionId), .integer(Int64(limit))] + } + do { + let rows = try await backend.query(sql, params: params) + // Caller wants chronological (oldest-first) order; the SELECT + // is DESC for the LIMIT to bite the newest rows, so reverse. + let messages = rows.map { messageFromRow($0) }.reversed() as [HermesMessage] + ScarfMon.event(.sessionLoad, "mac.fetchMessages.rows", count: messages.count) + return messages + } catch { + return [] + } } } diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift index 37feac0..8d6afa3 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift @@ -470,13 +470,15 @@ public final class RichChatViewModel { /// Re-fetch session metadata from DB to pick up cost/token updates. public func refreshSessionFromDB() async { - guard let sessionId else { return } - let opened = await dataService.open() - guard opened else { return } - if let session = await dataService.fetchSession(id: sessionId) { - currentSession = session + await ScarfMon.measureAsync(.sessionLoad, "mac.refreshSessionFromDB") { + guard let sessionId else { return } + let opened = await dataService.open() + guard opened else { return } + if let session = await dataService.fetchSession(id: sessionId) { + currentSession = session + } + await dataService.close() } - await dataService.close() } // MARK: - ACP Event Handling @@ -1015,6 +1017,7 @@ public final class RichChatViewModel { /// Load message history from the DB, optionally combining an origin session /// (e.g., CLI session) with the current ACP session. public func loadSessionHistory(sessionId: String, acpSessionId: String? = nil) async { + await ScarfMon.measureAsync(.sessionLoad, "mac.hydrateMessages") { self.sessionId = sessionId // Force a fresh snapshot pull on remote contexts. An earlier open() // would have cached a stale copy — on resume we need whatever @@ -1100,7 +1103,9 @@ public final class RichChatViewModel { .map(\.id) .min() hasMoreHistory = moreHistory + ScarfMon.event(.sessionLoad, "mac.hydrateMessages.rows", count: messages.count) buildMessageGroups() + } // end measureAsync(.sessionLoad, "mac.hydrateMessages") } // MARK: - Load Earlier (pagination) diff --git a/scarf/Packages/ScarfIOS/Sources/ScarfIOS/IOSDashboardViewModel.swift b/scarf/Packages/ScarfIOS/Sources/ScarfIOS/IOSDashboardViewModel.swift index f1d14f5..b4fea24 100644 --- a/scarf/Packages/ScarfIOS/Sources/ScarfIOS/IOSDashboardViewModel.swift +++ b/scarf/Packages/ScarfIOS/Sources/ScarfIOS/IOSDashboardViewModel.swift @@ -70,10 +70,13 @@ public final class IOSDashboardViewModel { return } - stats = await dataService.fetchStats() - recentSessions = await dataService.fetchSessions(limit: 5) - allSessions = await dataService.fetchSessions(limit: 25) - sessionPreviews = await dataService.fetchSessionPreviews(limit: 25) + await ScarfMon.measureAsync(.sessionLoad, "ios.loadDashboard") { + stats = await dataService.fetchStats() + recentSessions = await dataService.fetchSessions(limit: 5) + allSessions = await dataService.fetchSessions(limit: 25) + sessionPreviews = await dataService.fetchSessionPreviews(limit: 25) + } + ScarfMon.event(.sessionLoad, "ios.allSessions.count", count: allSessions.count) // Attribution lookup (pass-2 UX): load the session→project // sidecar + project registry once so Dashboard rows can show @@ -126,6 +129,7 @@ public final class IOSDashboardViewModel { /// Called from the pull-to-refresh gesture. public func refresh() async { + ScarfMon.event(.sessionLoad, "ios.dashboardRefresh.trigger", count: 1) await load() } } diff --git a/scarf/scarf/Core/Services/HermesFileWatcher.swift b/scarf/scarf/Core/Services/HermesFileWatcher.swift index f71909a..c43bff0 100644 --- a/scarf/scarf/Core/Services/HermesFileWatcher.swift +++ b/scarf/scarf/Core/Services/HermesFileWatcher.swift @@ -76,11 +76,21 @@ final class HermesFileWatcher { /// (Re)start the SSH polling stream over the union of `watchedCorePaths` /// and the current `remoteProjectPaths`. Called on initial start and /// whenever `updateProjectWatches` changes the project set. + /// + /// ScarfMon — `mac.fileWatcher.remoteRestart` (event) fires once per + /// poller restart with `bytes` carrying the path count. Frequent + /// restarts mean the project-list update path is churning; pair + /// with `mac.fileWatcher.remoteTick` from the upstream transport + /// (`ssh.streamScript` / `transport.watchPaths`) to see actual + /// poll cadence. private func startRemotePoller() { remotePollTask?.cancel() - let stream = transport.watchPaths(watchedCorePaths + remoteProjectPaths) + let pathSet = watchedCorePaths + remoteProjectPaths + ScarfMon.event(.transport, "mac.fileWatcher.remoteRestart", count: 1, bytes: pathSet.count) + let stream = transport.watchPaths(pathSet) remotePollTask = Task { [weak self] in for await _ in stream { + ScarfMon.event(.transport, "mac.fileWatcher.remoteDelta", count: 1) await MainActor.run { [weak self] in self?.lastChangeDate = Date() } @@ -146,6 +156,12 @@ final class HermesFileWatcher { queue: .main ) source.setEventHandler { [weak self] in + // ScarfMon — fires every time FSEvents detects a change on + // a watched core or project path. High counts during + // streaming chats are normal (state.db-wal ticks per + // message persisted); high counts when nothing's happening + // suggest a runaway watcher install. + ScarfMon.event(.transport, "mac.fileWatcher.localFire", count: 1) self?.lastChangeDate = Date() } source.setCancelHandler {