feat(scarfmon): chat + transport + sqlite measure points (Phase 2)

Wires ScarfMon measure points into the chat hot path on both targets,
plus the underlying SSH transport and remote-SQLite backend. All
callsites are surgical adds — no behavior change. Cost when ScarfMon
is in `.signpostOnly` (default) is one os_signpost emit per call,
elided by the runtime outside an Instruments session. In `.full` mode
the same callsites also push samples into the in-memory ring buffer.

Render counters (event):
- mac.ChatView.body / ios.ChatView.body — full transcript pane re-evals
- mac.RichMessageBubble.body / ios.MessageBubble.body — per-bubble re-evals

Stream + session (event + interval):
- mac.sendViaACP, mac.sendPrompt — user tap → first-byte
- mac.acpEvent, mac.handleACPEvent — per-event delivery + handle cost
- mac.startACPSession — session boot
- ios.send, ios.startResuming — same shape on iOS
- ios.acpEvent, ios.handleACPEvent — same per-event split on iOS

Transport + SQLite (interval, with byte counts on rows):
- ssh.streamScript (Citadel iOS) — SSH round-trip
- ssh.run (SSHScriptRunner Mac) — SSH round-trip
- sqlite.query, sqlite.queryBatch — Remote SQLite per-call
- sqlite.query.rows — row count + stdout bytes per query

Disk I/O (interval):
- diskIO.loadConfig — config.yaml read + parse
- diskIO.loadCronJobs — cron jobs.json decode

Body counters use the `let _: Void = ScarfMon.event(...)` pattern at
the top of `body` — works inside `@ViewBuilder` and fires on every
re-eval, which is exactly the signal we want.

To use:
  Mac: Settings → Advanced → Performance Diagnostics → Full
  iOS: Settings → Diagnostics → Performance → Full
Both panels auto-aggregate by (category, name), surface top 20 by
p95, and offer Copy as JSON for sharing in feedback threads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-04 22:18:06 +02:00
parent 6cf59c8a44
commit 3126c34561
8 changed files with 104 additions and 39 deletions
+27 -2
View File
@@ -66,7 +66,12 @@ struct ChatView: View {
)!
var body: some View {
VStack(spacing: 0) {
// ScarfMon body-evaluation counter. Re-render churn during
// streaming is one of the load-bearing perf signals; rendering
// here costs ~one signpost emit + ring-buffer append (off the
// hot path otherwise).
let _: Void = ScarfMon.event(.chatRender, "ios.ChatView.body")
return VStack(spacing: 0) {
connectionBanner
errorBanner
projectContextBar
@@ -1254,6 +1259,12 @@ final class ChatController {
/// assistant reply streams back as ACP notifications handled by
/// the event task.
func send() async {
await ScarfMon.measureAsync(.chatStream, "ios.send") {
await _sendImpl()
}
}
private func _sendImpl() async {
guard state == .ready, let client else { return }
let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
// v0.12+ allows image-only sends vision models accept "describe
@@ -1358,7 +1369,10 @@ final class ChatController {
let stream = await client.events
for await event in stream {
guard !Task.isCancelled else { break }
self?.vm.handleACPEvent(event)
ScarfMon.event(.chatStream, "ios.acpEvent", count: 1)
ScarfMon.measure(.chatStream, "ios.handleACPEvent") {
self?.vm.handleACPEvent(event)
}
}
// Stream ended if we weren't explicitly cancelled the
// channel died (EOF on stdin/out, write to dead pipe,
@@ -1788,6 +1802,12 @@ final class ChatController {
/// to `session/load` if the remote doesn't support `session/resume`
/// (Hermes < 0.9.x).
func startResuming(sessionID: String) async {
await ScarfMon.measureAsync(.sessionLoad, "ios.startResuming") {
await _startResumingImpl(sessionID: sessionID)
}
}
private func _startResumingImpl(sessionID: String) async {
guard await passModelPreflight(intent: .resume(sessionID: sessionID)) else { return }
await stop()
vm.reset()
@@ -1952,6 +1972,11 @@ private struct MessageBubble: View, Equatable {
}
var body: some View {
// Per-bubble render counter. The streaming bubble
// (`message.id == 0`) re-renders on every chunk; tracking the
// count here is what tells us if a slow chat is bottlenecked
// on body re-eval vs. event-loop delivery.
let _: Void = ScarfMon.event(.chatRender, "ios.MessageBubble.body")
if message.isToolResult {
ToolResultRow(message: message)
} else {