mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
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:
@@ -641,11 +641,23 @@ public final class RichChatViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func appendMessageChunk(text: String) {
|
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
|
streamingAssistantText += text
|
||||||
upsertStreamingMessage()
|
upsertStreamingMessage()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func appendThoughtChunk(text: String) {
|
private func appendThoughtChunk(text: String) {
|
||||||
|
if streamingThinkingText.isEmpty && currentTurnStart != nil {
|
||||||
|
ScarfMon.event(.chatStream, "firstThoughtByte", count: 1, bytes: text.utf8.count)
|
||||||
|
}
|
||||||
streamingThinkingText += text
|
streamingThinkingText += text
|
||||||
upsertStreamingMessage()
|
upsertStreamingMessage()
|
||||||
}
|
}
|
||||||
@@ -858,6 +870,12 @@ public final class RichChatViewModel {
|
|||||||
|
|
||||||
/// Convert the streaming message (id=0) into a permanent message and reset streaming state.
|
/// Convert the streaming message (id=0) into a permanent message and reset streaming state.
|
||||||
private func finalizeStreamingMessage() {
|
private func finalizeStreamingMessage() {
|
||||||
|
ScarfMon.measure(.chatStream, "finalizeStreamingMessage") {
|
||||||
|
_finalizeStreamingMessageImpl()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func _finalizeStreamingMessageImpl() {
|
||||||
guard let idx = messages.firstIndex(where: { $0.id == Self.streamingId }) else { return }
|
guard let idx = messages.firstIndex(where: { $0.id == Self.streamingId }) else { return }
|
||||||
|
|
||||||
// Only finalize if there's actual content
|
// Only finalize if there's actual content
|
||||||
|
|||||||
@@ -18,11 +18,24 @@ struct HermesFileService: Sendable {
|
|||||||
|
|
||||||
nonisolated func loadConfig() -> HermesConfig {
|
nonisolated func loadConfig() -> HermesConfig {
|
||||||
ScarfMon.measure(.diskIO, "loadConfig") {
|
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 }
|
guard let content = readFile(context.paths.configYAML) else { return .empty }
|
||||||
return parseConfig(content)
|
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
|
/// 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
|
/// specific reason when config.yaml can't be read on a remote host
|
||||||
/// (permission denied, missing file, sqlite3 not installed, etc.)
|
/// (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
|
/// we can reintroduce lazy with a preference-key-based height
|
||||||
/// measurement, but that's a much larger change.
|
/// measurement, but that's a much larger change.
|
||||||
var body: some View {
|
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 {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
if groups.isEmpty && !isWorking {
|
if groups.isEmpty && !isWorking {
|
||||||
|
|||||||
Reference in New Issue
Block a user