mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 02:26:37 +00:00
feat(scarfmon): B2 + B3 + iOS dashboard — file watcher, message hydration, dashboard load
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) <noreply@anthropic.com>
This commit is contained in:
@@ -188,22 +188,26 @@ public actor HermesDataService {
|
|||||||
limit: Int,
|
limit: Int,
|
||||||
before: Int? = nil
|
before: Int? = nil
|
||||||
) async -> [HermesMessage] {
|
) async -> [HermesMessage] {
|
||||||
let sql: String
|
await ScarfMon.measureAsync(.sessionLoad, "mac.fetchMessages") {
|
||||||
let params: [SQLValue]
|
let sql: String
|
||||||
if let before {
|
let params: [SQLValue]
|
||||||
sql = "SELECT \(messageColumns) FROM messages WHERE session_id = ? AND id < ? ORDER BY id DESC LIMIT ?"
|
if let before {
|
||||||
params = [.text(sessionId), .integer(Int64(before)), .integer(Int64(limit))]
|
sql = "SELECT \(messageColumns) FROM messages WHERE session_id = ? AND id < ? ORDER BY id DESC LIMIT ?"
|
||||||
} else {
|
params = [.text(sessionId), .integer(Int64(before)), .integer(Int64(limit))]
|
||||||
sql = "SELECT \(messageColumns) FROM messages WHERE session_id = ? ORDER BY id DESC LIMIT ?"
|
} else {
|
||||||
params = [.text(sessionId), .integer(Int64(limit))]
|
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)
|
do {
|
||||||
// Caller wants chronological (oldest-first) order; the SELECT
|
let rows = try await backend.query(sql, params: params)
|
||||||
// is DESC for the LIMIT to bite the newest rows, so reverse.
|
// Caller wants chronological (oldest-first) order; the SELECT
|
||||||
return rows.map { messageFromRow($0) }.reversed()
|
// is DESC for the LIMIT to bite the newest rows, so reverse.
|
||||||
} catch {
|
let messages = rows.map { messageFromRow($0) }.reversed() as [HermesMessage]
|
||||||
return []
|
ScarfMon.event(.sessionLoad, "mac.fetchMessages.rows", count: messages.count)
|
||||||
|
return messages
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -470,13 +470,15 @@ public final class RichChatViewModel {
|
|||||||
|
|
||||||
/// Re-fetch session metadata from DB to pick up cost/token updates.
|
/// Re-fetch session metadata from DB to pick up cost/token updates.
|
||||||
public func refreshSessionFromDB() async {
|
public func refreshSessionFromDB() async {
|
||||||
guard let sessionId else { return }
|
await ScarfMon.measureAsync(.sessionLoad, "mac.refreshSessionFromDB") {
|
||||||
let opened = await dataService.open()
|
guard let sessionId else { return }
|
||||||
guard opened else { return }
|
let opened = await dataService.open()
|
||||||
if let session = await dataService.fetchSession(id: sessionId) {
|
guard opened else { return }
|
||||||
currentSession = session
|
if let session = await dataService.fetchSession(id: sessionId) {
|
||||||
|
currentSession = session
|
||||||
|
}
|
||||||
|
await dataService.close()
|
||||||
}
|
}
|
||||||
await dataService.close()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - ACP Event Handling
|
// MARK: - ACP Event Handling
|
||||||
@@ -1015,6 +1017,7 @@ public final class RichChatViewModel {
|
|||||||
/// Load message history from the DB, optionally combining an origin session
|
/// Load message history from the DB, optionally combining an origin session
|
||||||
/// (e.g., CLI session) with the current ACP session.
|
/// (e.g., CLI session) with the current ACP session.
|
||||||
public func loadSessionHistory(sessionId: String, acpSessionId: String? = nil) async {
|
public func loadSessionHistory(sessionId: String, acpSessionId: String? = nil) async {
|
||||||
|
await ScarfMon.measureAsync(.sessionLoad, "mac.hydrateMessages") {
|
||||||
self.sessionId = sessionId
|
self.sessionId = sessionId
|
||||||
// Force a fresh snapshot pull on remote contexts. An earlier open()
|
// Force a fresh snapshot pull on remote contexts. An earlier open()
|
||||||
// would have cached a stale copy — on resume we need whatever
|
// would have cached a stale copy — on resume we need whatever
|
||||||
@@ -1100,7 +1103,9 @@ public final class RichChatViewModel {
|
|||||||
.map(\.id)
|
.map(\.id)
|
||||||
.min()
|
.min()
|
||||||
hasMoreHistory = moreHistory
|
hasMoreHistory = moreHistory
|
||||||
|
ScarfMon.event(.sessionLoad, "mac.hydrateMessages.rows", count: messages.count)
|
||||||
buildMessageGroups()
|
buildMessageGroups()
|
||||||
|
} // end measureAsync(.sessionLoad, "mac.hydrateMessages")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Load Earlier (pagination)
|
// MARK: - Load Earlier (pagination)
|
||||||
|
|||||||
@@ -70,10 +70,13 @@ public final class IOSDashboardViewModel {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
stats = await dataService.fetchStats()
|
await ScarfMon.measureAsync(.sessionLoad, "ios.loadDashboard") {
|
||||||
recentSessions = await dataService.fetchSessions(limit: 5)
|
stats = await dataService.fetchStats()
|
||||||
allSessions = await dataService.fetchSessions(limit: 25)
|
recentSessions = await dataService.fetchSessions(limit: 5)
|
||||||
sessionPreviews = await dataService.fetchSessionPreviews(limit: 25)
|
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
|
// Attribution lookup (pass-2 UX): load the session→project
|
||||||
// sidecar + project registry once so Dashboard rows can show
|
// sidecar + project registry once so Dashboard rows can show
|
||||||
@@ -126,6 +129,7 @@ public final class IOSDashboardViewModel {
|
|||||||
|
|
||||||
/// Called from the pull-to-refresh gesture.
|
/// Called from the pull-to-refresh gesture.
|
||||||
public func refresh() async {
|
public func refresh() async {
|
||||||
|
ScarfMon.event(.sessionLoad, "ios.dashboardRefresh.trigger", count: 1)
|
||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,11 +76,21 @@ final class HermesFileWatcher {
|
|||||||
/// (Re)start the SSH polling stream over the union of `watchedCorePaths`
|
/// (Re)start the SSH polling stream over the union of `watchedCorePaths`
|
||||||
/// and the current `remoteProjectPaths`. Called on initial start and
|
/// and the current `remoteProjectPaths`. Called on initial start and
|
||||||
/// whenever `updateProjectWatches` changes the project set.
|
/// 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() {
|
private func startRemotePoller() {
|
||||||
remotePollTask?.cancel()
|
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
|
remotePollTask = Task { [weak self] in
|
||||||
for await _ in stream {
|
for await _ in stream {
|
||||||
|
ScarfMon.event(.transport, "mac.fileWatcher.remoteDelta", count: 1)
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
self?.lastChangeDate = Date()
|
self?.lastChangeDate = Date()
|
||||||
}
|
}
|
||||||
@@ -146,6 +156,12 @@ final class HermesFileWatcher {
|
|||||||
queue: .main
|
queue: .main
|
||||||
)
|
)
|
||||||
source.setEventHandler { [weak self] in
|
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()
|
self?.lastChangeDate = Date()
|
||||||
}
|
}
|
||||||
source.setCancelHandler {
|
source.setCancelHandler {
|
||||||
|
|||||||
Reference in New Issue
Block a user