feat(scarfmon): A1 — instrument iOS file-watcher polling cadence

Adds three measure points to CitadelServerTransport.watchPaths:

- ios.fileWatcher.tick (interval) — full poll cycle latency including
  the SSH stat round-trips. > 1500ms here is what 'out of sync' feels
  like — the channel is congested or the host is slow.
- ios.fileWatcher.delta (event) — fires only when the signature
  actually changed. Low delta/tick ratio means we can safely drop
  the 3-second cadence; high ratio means we'd just burn bandwidth.
- ios.fileWatcher.paths (event, bytes=count) — number of paths watched
  per cycle. Explains slow ticks as the project list grows.

Surgical addition; existing 3-second cadence + signature-diff logic
unchanged. With Full mode on, a few minutes of usage on LAN will
tell us empirically whether the cadence can drop to 1s — the
original v2.6.0 user feedback complained 'chat is out of sync'
between iOS and Mac on a fast LAN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-04 23:33:30 +02:00
parent 9ff9a018e7
commit 9df7142f49
@@ -272,14 +272,32 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable
// Polling-based, identical in shape to `SSHTransport`'s remote-
// watch fallback: stat each path, yield `.anyChanged` when any
// mtime shifts. 3s tick keeps bandwidth low.
//
// ScarfMon A1 instrumentation:
// - `ios.fileWatcher.tick` (interval) full poll cycle latency,
// includes the SSH stat round-trips. Pre-fix this is what an
// "out of sync" user is feeling: anything > 1500 ms means
// the channel is congested or the host is slow.
// - `ios.fileWatcher.delta` (event) fires only when the
// signature actually changed. Low ratio (delta count / tick
// count) means we're polling more aggressively than the
// change rate warrants opens the door to dropping the 3s
// cadence on LAN.
// - `ios.fileWatcher.paths` (event with bytes=count) number
// of paths watched per cycle, helps explain a slow tick when
// the project list grows.
AsyncStream { continuation in
let task = Task.detached { [weak self] in
var lastSignature = ""
while !Task.isCancelled {
guard let self else { break }
let current = await self.buildWatchSignature(for: paths)
ScarfMon.event(.transport, "ios.fileWatcher.paths", count: 1, bytes: paths.count)
let current = await ScarfMon.measureAsync(.transport, "ios.fileWatcher.tick") {
await self.buildWatchSignature(for: paths)
}
if !current.isEmpty, current != lastSignature {
if !lastSignature.isEmpty {
ScarfMon.event(.transport, "ios.fileWatcher.delta", count: 1)
continuation.yield(.anyChanged)
}
lastSignature = current