feat(scarfmon): Phase 3a — diagnostic measure points for chat-render bursts

Adds four targeted measure points so the next baseline capture can
attribute the bubble-re-render storm and the slow sendPrompt to a
specific cause:

- mac.RichChatMessageList.body — distinguishes "the parent is
  re-issuing the ForEach" from "the bubbles are re-rendering on their
  own". If list.body fires once and bubble.body fires N times, churn
  is in the bubbles; if list.body fires N times, the ForEach itself
  is being rebuilt.
- finalizeStreamingMessage (interval) — pinpoints the end-of-stream
  burst trigger. The 20-bubble re-eval burst we saw at the close of
  each turn lines up with this call; measuring it surfaces whether
  it's the streaming-id rewrite, the turn-duration assignment, or
  something downstream.
- firstByte / firstThoughtByte (event) — fires once per turn on the
  first chunk after currentTurnStart is set. Splits user-tap → first
  byte (network + Hermes thinking, the dominant component of the 7-11s
  sendPrompt) from first byte → turn end (Scarf streaming render).
- loadConfig caller hint via os.Logger — when ScarfMon is in Full mode,
  logs the first stack frame above each loadConfig call to the
  com.scarf.mon subsystem so mystery callers (the read at t=264282
  with no apparent trigger in the prior baseline) become traceable
  via `log stream`. Symbol-only, no PII, free outside Full mode.

All four are pure additions — no behavior change, same zero-cost
default-off semantics as Phase 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-04 22:47:29 +02:00
parent 3126c34561
commit 0a4f8de492
3 changed files with 37 additions and 1 deletions
@@ -641,11 +641,23 @@ public final class RichChatViewModel {
}
private func appendMessageChunk(text: String) {
// ScarfMon "first byte" fires once per turn, on the first
// visible message chunk. Splits "user tap first byte"
// (network + Hermes thinking) from "first byte turn end"
// (streaming + Scarf rendering) so we can attribute slow-feel
// bugs to the right side. `bytes` carries the first chunk's
// size, not the full turn.
if streamingAssistantText.isEmpty && currentTurnStart != nil {
ScarfMon.event(.chatStream, "firstByte", count: 1, bytes: text.utf8.count)
}
streamingAssistantText += text
upsertStreamingMessage()
}
private func appendThoughtChunk(text: String) {
if streamingThinkingText.isEmpty && currentTurnStart != nil {
ScarfMon.event(.chatStream, "firstThoughtByte", count: 1, bytes: text.utf8.count)
}
streamingThinkingText += text
upsertStreamingMessage()
}
@@ -858,6 +870,12 @@ public final class RichChatViewModel {
/// Convert the streaming message (id=0) into a permanent message and reset streaming state.
private func finalizeStreamingMessage() {
ScarfMon.measure(.chatStream, "finalizeStreamingMessage") {
_finalizeStreamingMessageImpl()
}
}
private func _finalizeStreamingMessageImpl() {
guard let idx = messages.firstIndex(where: { $0.id == Self.streamingId }) else { return }
// Only finalize if there's actual content
@@ -18,11 +18,24 @@ struct HermesFileService: Sendable {
nonisolated func loadConfig() -> HermesConfig {
ScarfMon.measure(.diskIO, "loadConfig") {
// ScarfMon when Full mode is on, log the first stack
// frame above this call to the perf Logger channel so
// mystery callers (e.g. config reads with no user action)
// can be identified by tailing
// `log stream --predicate 'subsystem == "com.scarf.mon"'`.
// Symbol-only no addresses, no PII. Backtrace alloc is
// gated on isActive so it's free outside Full mode.
if ScarfMon.isActive {
let caller = Thread.callStackSymbols.dropFirst(2).first ?? "<unknown>"
Self.perfLogger.debug("loadConfig caller: \(caller, privacy: .public)")
}
guard let content = readFile(context.paths.configYAML) else { return .empty }
return parseConfig(content)
}
}
private static let perfLogger = Logger(subsystem: "com.scarf.mon", category: "HermesFileService")
/// Error-surfacing config load. Used by Dashboard to show the user a
/// specific reason when config.yaml can't be read on a remote host
/// (permission denied, missing file, sqlite3 not installed, etc.)
@@ -41,7 +41,12 @@ struct RichChatMessageList: View {
/// we can reintroduce lazy with a preference-key-based height
/// measurement, but that's a much larger change.
var body: some View {
ScrollViewReader { proxy in
// ScarfMon confirms whether the parent re-issues the
// ForEach. If this fires once and we still see RichMessageBubble.body
// burst N times, churn lives inside the bubbles (or in their inputs).
// If this fires N times, the ForEach itself is being rebuilt.
let _: Void = ScarfMon.event(.chatRender, "mac.RichChatMessageList.body")
return ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if groups.isEmpty && !isWorking {