native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Watch process memory and emit baseline + threshold crossings

Prowl users report that after running for hours the app drifts from a
500–600 MB working set to tens of gigabytes. The observability we have
up to now (crashes, events, super properties) doesn't surface that
trajectory — a hang or crash might fire eventually, but the growth
between a healthy baseline and the explosion is invisible.

Add MemoryProbe + MemoryWatchdog so every session contributes exactly
enough signal to reconstruct that trajectory without drowning the free
tier:

- MemoryProbe reads phys_footprint via task_info(TASK_VM_INFO). That's
the same number Activity Monitor shows and what Apple recommends for
"this app's contribution to RAM pressure" (folds in compressed pages).
Marked nonisolated so it can be consumed from any actor.
- MemoryWatchdog ticks every 5 minutes on a weak-ref Task. Fires:
* app_memory_baseline once, at 3 min uptime — the clean working set
after first-time-setup settles.
* memory_threshold_2048mb / 4096mb / 8192mb — each at most once per
session when phys_footprint first crosses that floor. 4GB+ also
routes through SentrySDK.capture(message:) so Sentry dashboards
pair the spike with action breadcrumbs + device context.
Re-crossing a threshold after dropping does NOT re-fire — intentional,
because we want the monotonic envelope of the session, not noise.
- Every event carries repository_count / opened_worktree_count /
terminal_tab_count in addition to resident_mb + growth_ratio, so
PostHog can answer "does the leak track worktree count?" via a single
query.

The watchdog is constructed regardless of build config but only start()s
in Release — the analytics path is #if !DEBUG gated downstream anyway.
Context provider captures appStore + terminalManager so the watchdog
itself stays decoupled from TCA.

6 unit tests cover: no baseline before delay, baseline once-only,
threshold once-each including Sentry routing at 4GB+, re-cross is a
no-op, thresholds stay silent without a baseline, property payload
content.

onevcat 89a36ee4 a2947c0b

+348
+17
supacode/App/supacodeApp.swift
··· 95 95 @State private var commandKeyObserver: CommandKeyObserver 96 96 @State private var cliSocketServer: CLISocketServer 97 97 @State private var store: StoreOf<AppFeature> 98 + @State private var memoryWatchdog: MemoryWatchdog 98 99 99 100 private static func cliLaunchOpenPath() -> String? { 100 101 let args = ProcessInfo.processInfo.arguments ··· 216 217 217 218 let cliServer = Self.makeCLISocketServer(appStore: appStore, terminalManager: terminalManager) 218 219 _cliSocketServer = State(initialValue: cliServer) 220 + 221 + let watchdog = MemoryWatchdog( 222 + analyticsCapture: AnalyticsClient.liveValue.capture, 223 + contextProvider: { [appStore, terminalManager] in 224 + let state = appStore.state 225 + return MemoryWatchdog.Context( 226 + repositoryCount: state.repositories.repositories.count, 227 + openedWorktreeCount: state.repositories.repositories.flatMap(\.worktrees).count, 228 + terminalTabCount: terminalManager.activeWorktreeStates.flatMap(\.tabManager.tabs).count 229 + ) 230 + } 231 + ) 232 + #if !DEBUG 233 + watchdog.start() 234 + #endif 235 + _memoryWatchdog = State(initialValue: watchdog) 219 236 220 237 runtime.onQuit = { [weak appStore] in 221 238 appStore?.send(.requestQuit)
+23
supacode/Support/MemoryProbe.swift
··· 1 + import Darwin.Mach 2 + import Foundation 3 + 4 + /// Reads the current process's physical memory footprint in megabytes. 5 + /// 6 + /// Uses `phys_footprint` from `task_info(TASK_VM_INFO)` — the same value 7 + /// Activity Monitor reports for "Memory" and Apple's recommended metric for 8 + /// "real RAM pressure this app contributes" (it folds in compressed memory). 9 + nonisolated enum MemoryProbe { 10 + static func physFootprintMegabytes() -> Int { 11 + var info = task_vm_info_data_t() 12 + var count = mach_msg_type_number_t( 13 + MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<integer_t>.size 14 + ) 15 + let result = withUnsafeMutablePointer(to: &info) { pointer -> kern_return_t in 16 + pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { reboundPointer in 17 + task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), reboundPointer, &count) 18 + } 19 + } 20 + guard result == KERN_SUCCESS else { return 0 } 21 + return Int(info.phys_footprint / (1024 * 1024)) 22 + } 23 + }
+142
supacode/Support/MemoryWatchdog.swift
··· 1 + import Foundation 2 + import Sentry 3 + 4 + /// Monitors process memory footprint over the app's lifetime and emits exactly 5 + /// these analytics events per session: 6 + /// 7 + /// - `app_memory_baseline` fires once, `baselineDelay` seconds after start, 8 + /// establishing the steady-state working set for this launch. 9 + /// - `memory_threshold_<N>mb` fires at most once per threshold per session 10 + /// when phys_footprint first crosses N. 4GB+ additionally surfaces a Sentry 11 + /// event so dashboards pair the spike with breadcrumbs/context. 12 + /// 13 + /// Noise-controlled by design — long-lived sessions with stable memory emit 14 + /// exactly one event (the baseline); only genuine growth produces more. 15 + @MainActor 16 + @Observable 17 + final class MemoryWatchdog { 18 + struct Context: Sendable, Equatable { 19 + let repositoryCount: Int 20 + let openedWorktreeCount: Int 21 + let terminalTabCount: Int 22 + } 23 + 24 + typealias AnalyticsCapture = @Sendable (_ event: String, _ properties: [String: Any]?) -> Void 25 + typealias SentryCapture = @Sendable (_ message: String) -> Void 26 + 27 + private let probe: @Sendable () -> Int 28 + private let clock: @Sendable () -> Date 29 + private let tickInterval: TimeInterval 30 + private let baselineDelay: TimeInterval 31 + private let thresholdsMB: [Int] 32 + private let sentryThresholdMB: Int 33 + private let analyticsCapture: AnalyticsCapture 34 + private let sentryCapture: SentryCapture 35 + private let contextProvider: @MainActor () -> Context 36 + 37 + private let startedAt: Date 38 + private(set) var baselineMB: Int? 39 + private(set) var firedThresholds: Set<Int> = [] 40 + private var tickTask: Task<Void, Never>? 41 + 42 + init( 43 + probe: @escaping @Sendable () -> Int = MemoryProbe.physFootprintMegabytes, 44 + clock: @escaping @Sendable () -> Date = Date.init, 45 + tickInterval: TimeInterval = 300, 46 + baselineDelay: TimeInterval = 180, 47 + thresholdsMB: [Int] = [2048, 4096, 8192], 48 + sentryThresholdMB: Int = 4096, 49 + analyticsCapture: @escaping AnalyticsCapture, 50 + sentryCapture: @escaping SentryCapture = { SentrySDK.capture(message: $0) }, 51 + contextProvider: @escaping @MainActor () -> Context 52 + ) { 53 + self.probe = probe 54 + self.clock = clock 55 + self.tickInterval = tickInterval 56 + self.baselineDelay = baselineDelay 57 + self.thresholdsMB = thresholdsMB.sorted() 58 + self.sentryThresholdMB = sentryThresholdMB 59 + self.analyticsCapture = analyticsCapture 60 + self.sentryCapture = sentryCapture 61 + self.contextProvider = contextProvider 62 + self.startedAt = clock() 63 + } 64 + 65 + /// Begins periodic ticking on a background Task. Safe to call more than once. 66 + func start() { 67 + tickTask?.cancel() 68 + let interval = tickInterval 69 + tickTask = Task { [weak self] in 70 + while !Task.isCancelled { 71 + try? await Task.sleep(for: .seconds(interval)) 72 + guard !Task.isCancelled else { return } 73 + self?.tick() 74 + } 75 + } 76 + } 77 + 78 + func stop() { 79 + tickTask?.cancel() 80 + tickTask = nil 81 + } 82 + 83 + /// One monitoring pass. Exposed for tests; `start()` drives it on a schedule. 84 + func tick() { 85 + let now = clock() 86 + let currentMB = probe() 87 + let uptime = now.timeIntervalSince(startedAt) 88 + 89 + if baselineMB == nil, uptime >= baselineDelay { 90 + baselineMB = currentMB 91 + let ctx = contextProvider() 92 + analyticsCapture("app_memory_baseline", baselineProperties(currentMB: currentMB, uptime: uptime, context: ctx)) 93 + } 94 + 95 + guard let baseline = baselineMB else { return } 96 + 97 + for threshold in thresholdsMB where currentMB >= threshold && !firedThresholds.contains(threshold) { 98 + firedThresholds.insert(threshold) 99 + let ctx = contextProvider() 100 + let props = thresholdProperties( 101 + currentMB: currentMB, 102 + baselineMB: baseline, 103 + uptime: uptime, 104 + context: ctx 105 + ) 106 + analyticsCapture("memory_threshold_\(threshold)mb", props) 107 + if threshold >= sentryThresholdMB { 108 + sentryCapture( 109 + "Memory threshold \(threshold) MB crossed (current=\(currentMB)MB, baseline=\(baseline)MB)" 110 + ) 111 + } 112 + } 113 + } 114 + 115 + private func baselineProperties(currentMB: Int, uptime: TimeInterval, context: Context) -> [String: Any] { 116 + [ 117 + "resident_mb": currentMB, 118 + "uptime_seconds": Int(uptime), 119 + "repository_count": context.repositoryCount, 120 + "opened_worktree_count": context.openedWorktreeCount, 121 + "terminal_tab_count": context.terminalTabCount, 122 + ] 123 + } 124 + 125 + private func thresholdProperties( 126 + currentMB: Int, 127 + baselineMB: Int, 128 + uptime: TimeInterval, 129 + context: Context 130 + ) -> [String: Any] { 131 + let growth = baselineMB > 0 ? (Double(currentMB) / Double(baselineMB)) : 0 132 + return [ 133 + "resident_mb": currentMB, 134 + "baseline_mb": baselineMB, 135 + "growth_ratio": (growth * 100).rounded() / 100, 136 + "uptime_seconds": Int(uptime), 137 + "repository_count": context.repositoryCount, 138 + "opened_worktree_count": context.openedWorktreeCount, 139 + "terminal_tab_count": context.terminalTabCount, 140 + ] 141 + } 142 + }
+166
supacodeTests/MemoryWatchdogTests.swift
··· 1 + import ConcurrencyExtras 2 + import Foundation 3 + import Testing 4 + 5 + @testable import supacode 6 + 7 + @MainActor 8 + struct MemoryWatchdogTests { 9 + @Test func baselineDoesNotFireBeforeDelay() { 10 + let env = makeEnv(baselineMB: 500) 11 + env.now.setValue(baseDate.addingTimeInterval(60)) 12 + env.watchdog.tick() 13 + #expect(env.events.value.isEmpty) 14 + #expect(env.watchdog.baselineMB == nil) 15 + } 16 + 17 + @Test func baselineFiresAtDelayAndOnlyOnce() { 18 + let env = makeEnv(baselineMB: 500) 19 + env.now.setValue(baseDate.addingTimeInterval(180)) 20 + env.watchdog.tick() 21 + #expect(env.events.value.count == 1) 22 + #expect(env.events.value[0].event == "app_memory_baseline") 23 + #expect(env.events.value[0].residentMB == 500) 24 + #expect(env.watchdog.baselineMB == 500) 25 + 26 + env.now.setValue(baseDate.addingTimeInterval(600)) 27 + env.watchdog.tick() 28 + #expect(env.events.value.count == 1, "baseline must not fire twice") 29 + } 30 + 31 + @Test func thresholdFiresOnceEachAndSentryAtOrAbove4GB() { 32 + let env = makeEnv(baselineMB: 500) 33 + env.now.setValue(baseDate.addingTimeInterval(180)) 34 + env.watchdog.tick() 35 + 36 + env.currentMB.setValue(2_500) 37 + env.now.setValue(baseDate.addingTimeInterval(3_600)) 38 + env.watchdog.tick() 39 + env.currentMB.setValue(5_000) 40 + env.now.setValue(baseDate.addingTimeInterval(7_200)) 41 + env.watchdog.tick() 42 + env.currentMB.setValue(9_000) 43 + env.now.setValue(baseDate.addingTimeInterval(10_800)) 44 + env.watchdog.tick() 45 + 46 + let thresholdEvents = env.events.value.map(\.event).filter { $0.hasPrefix("memory_threshold_") } 47 + #expect(thresholdEvents == ["memory_threshold_2048mb", "memory_threshold_4096mb", "memory_threshold_8192mb"]) 48 + 49 + env.watchdog.tick() 50 + let afterReplay = env.events.value.map(\.event).filter { $0.hasPrefix("memory_threshold_") } 51 + #expect(afterReplay.count == 3, "thresholds must not re-fire") 52 + 53 + #expect(env.sentryMessages.value.count == 2, "Sentry fires for 4GB and 8GB only") 54 + } 55 + 56 + @Test func droppingBelowThresholdDoesNotRearm() { 57 + let env = makeEnv(baselineMB: 500) 58 + env.now.setValue(baseDate.addingTimeInterval(180)) 59 + env.watchdog.tick() 60 + 61 + env.currentMB.setValue(2_500) 62 + env.now.setValue(baseDate.addingTimeInterval(3_600)) 63 + env.watchdog.tick() 64 + env.currentMB.setValue(800) 65 + env.now.setValue(baseDate.addingTimeInterval(7_200)) 66 + env.watchdog.tick() 67 + env.currentMB.setValue(2_500) 68 + env.now.setValue(baseDate.addingTimeInterval(10_800)) 69 + env.watchdog.tick() 70 + 71 + let thresholdEvents = env.events.value.map(\.event).filter { $0.hasPrefix("memory_threshold_") } 72 + #expect(thresholdEvents == ["memory_threshold_2048mb"]) 73 + } 74 + 75 + @Test func thresholdsNeverFireWithoutBaseline() { 76 + let env = makeEnv(baselineMB: 3_000) 77 + env.now.setValue(baseDate.addingTimeInterval(30)) 78 + env.watchdog.tick() 79 + #expect(env.events.value.isEmpty) 80 + #expect(env.sentryMessages.value.isEmpty) 81 + } 82 + 83 + @Test func thresholdPropertiesIncludeContextAndGrowthRatio() { 84 + let env = makeEnv(baselineMB: 500) 85 + env.now.setValue(baseDate.addingTimeInterval(180)) 86 + env.watchdog.tick() 87 + 88 + env.currentMB.setValue(2_500) 89 + env.now.setValue(baseDate.addingTimeInterval(3_600)) 90 + env.watchdog.tick() 91 + 92 + let event = env.events.value.last { $0.event == "memory_threshold_2048mb" } 93 + #expect(event?.residentMB == 2_500) 94 + #expect(event?.baselineMB == 500) 95 + #expect(event?.growthRatio == 5.0) 96 + #expect(event?.repositoryCount == 1) 97 + #expect(event?.openedWorktreeCount == 2) 98 + #expect(event?.terminalTabCount == 3) 99 + } 100 + 101 + // MARK: - Test helpers 102 + 103 + private let baseDate = Date(timeIntervalSince1970: 1_700_000_000) 104 + 105 + private nonisolated struct CapturedEvent: Sendable, Equatable { 106 + let event: String 107 + let residentMB: Int? 108 + let baselineMB: Int? 109 + let growthRatio: Double? 110 + let uptimeSeconds: Int? 111 + let repositoryCount: Int? 112 + let openedWorktreeCount: Int? 113 + let terminalTabCount: Int? 114 + 115 + init(event: String, properties: [String: Any]) { 116 + self.event = event 117 + residentMB = properties["resident_mb"] as? Int 118 + baselineMB = properties["baseline_mb"] as? Int 119 + growthRatio = properties["growth_ratio"] as? Double 120 + uptimeSeconds = properties["uptime_seconds"] as? Int 121 + repositoryCount = properties["repository_count"] as? Int 122 + openedWorktreeCount = properties["opened_worktree_count"] as? Int 123 + terminalTabCount = properties["terminal_tab_count"] as? Int 124 + } 125 + } 126 + 127 + private struct Env { 128 + let watchdog: MemoryWatchdog 129 + let currentMB: LockIsolated<Int> 130 + let now: LockIsolated<Date> 131 + let events: LockIsolated<[CapturedEvent]> 132 + let sentryMessages: LockIsolated<[String]> 133 + } 134 + 135 + private func makeEnv(baselineMB: Int) -> Env { 136 + let currentMB = LockIsolated(baselineMB) 137 + let now = LockIsolated(baseDate) 138 + let events = LockIsolated<[CapturedEvent]>([]) 139 + let sentryMessages = LockIsolated<[String]>([]) 140 + let watchdog = MemoryWatchdog( 141 + probe: { currentMB.value }, 142 + clock: { now.value }, 143 + tickInterval: 300, 144 + baselineDelay: 180, 145 + thresholdsMB: [2_048, 4_096, 8_192], 146 + sentryThresholdMB: 4_096, 147 + analyticsCapture: { event, properties in 148 + let captured = CapturedEvent(event: event, properties: properties ?? [:]) 149 + events.withValue { $0.append(captured) } 150 + }, 151 + sentryCapture: { message in 152 + sentryMessages.withValue { $0.append(message) } 153 + }, 154 + contextProvider: { 155 + .init(repositoryCount: 1, openedWorktreeCount: 2, terminalTabCount: 3) 156 + } 157 + ) 158 + return Env( 159 + watchdog: watchdog, 160 + currentMB: currentMB, 161 + now: now, 162 + events: events, 163 + sentryMessages: sentryMessages 164 + ) 165 + } 166 + }