From 32f8b019602eac03c05376542000879130730e15 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:12:30 +0000 Subject: [PATCH 1/5] [LUM-782] Fix hang in performHealthCheck by caching active assistant Cache a LockfileAssistant snapshot on GatewayConnectionManager instead of reading the lockfile from disk on every health check cycle. This eliminates 3 synchronous file I/O + JSON parse calls that blocked the main thread every 15 seconds (2 seconds during updates). - Add cachedAssistant property, refreshed on connect/reconfigure and when activeAssistantDidChange fires - Replace synchronous lockfile reads in isLocal, isManaged, performHealthCheck, and handleAuthenticationFailure with cached values - Move updateServiceGroupVersion off the main actor via Task.detached Co-Authored-By: tkheyfets --- .../Network/GatewayConnectionManager.swift | 78 +++++++++++++++---- 1 file changed, 62 insertions(+), 16 deletions(-) diff --git a/clients/shared/Network/GatewayConnectionManager.swift b/clients/shared/Network/GatewayConnectionManager.swift index 718d4715612..1c11a0eebf3 100644 --- a/clients/shared/Network/GatewayConnectionManager.swift +++ b/clients/shared/Network/GatewayConnectionManager.swift @@ -53,14 +53,21 @@ public final class GatewayConnectionManager: ObservableObject { // MARK: - Connection State (internal) + #if os(macOS) + /// Cached snapshot of the active assistant from the lockfile. + /// Refreshed on connect, reconfigure, and when the active assistant changes + /// externally (e.g. CLI `vellum use`). Reads from this cache replace the + /// synchronous `LockfileAssistant.loadAll()` calls that previously blocked + /// the main thread on every health check cycle. + private var cachedAssistant: LockfileAssistant? + private var assistantChangeObserver: NSObjectProtocol? + #endif + /// Whether auto-wake should be attempted on disconnect. /// Applies to local and Docker assistants (not remote or managed). private var isLocal: Bool { #if os(macOS) - guard let id = LockfileAssistant.loadActiveAssistantId(), - let assistant = LockfileAssistant.loadByName(id) else { - return false - } + guard let assistant = cachedAssistant else { return false } return (!assistant.isRemote || assistant.isDocker) && !assistant.isManaged #else return false @@ -70,11 +77,7 @@ public final class GatewayConnectionManager: ObservableObject { /// Whether the connected assistant is a managed (platform-hosted) assistant. private var isManaged: Bool { #if os(macOS) - guard let id = LockfileAssistant.loadActiveAssistantId(), - let assistant = LockfileAssistant.loadByName(id) else { - return false - } - return assistant.isManaged + return cachedAssistant?.isManaged ?? false #else return false #endif @@ -146,6 +149,16 @@ public final class GatewayConnectionManager: ObservableObject { // macOS re-reads from disk on each request; no persistence needed here. #endif } + + #if os(macOS) + assistantChangeObserver = NotificationCenter.default.addObserver( + forName: LockfileAssistant.activeAssistantDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + self?.refreshCachedAssistant() + } + #endif } // MARK: - Connect @@ -161,6 +174,10 @@ public final class GatewayConnectionManager: ObservableObject { #endif disconnectInternal(cancelAutoWake: cancelAutoWake) + #if os(macOS) + refreshCachedAssistant() + #endif + isConnecting = true if let conversationKey, !conversationKey.isEmpty { @@ -261,6 +278,7 @@ public final class GatewayConnectionManager: ObservableObject { refreshTask = nil #if os(macOS) lastAutoWakeAttempt = nil + cachedAssistant = nil #endif // Reset published state @@ -288,8 +306,12 @@ public final class GatewayConnectionManager: ObservableObject { private func performHealthCheck() async throws { do { - let isManaged = (try? GatewayHTTPClient.isConnectionManaged()) ?? false - let healthPath = isManaged ? "assistants/{assistantId}/health" : "health" + let healthPath: String + #if os(macOS) + healthPath = (cachedAssistant?.isManaged ?? false) ? "assistants/{assistantId}/health" : "health" + #else + healthPath = "health" + #endif let response = try await GatewayHTTPClient.get( path: healthPath, timeout: 10, @@ -307,8 +329,11 @@ public final class GatewayConnectionManager: ObservableObject { if let newVersion = decoded.version, newVersion != assistantVersion { assistantVersion = newVersion #if os(macOS) - if let id = LockfileAssistant.loadActiveAssistantId(), !id.isEmpty { - LockfilePaths.updateServiceGroupVersion(assistantId: id, version: newVersion) + if let id = cachedAssistant?.assistantId, !id.isEmpty { + let capturedId = id + Task.detached { + LockfilePaths.updateServiceGroupVersion(assistantId: capturedId, version: newVersion) + } } #endif handleDaemonVersionChanged(newVersion) @@ -498,8 +523,12 @@ public final class GatewayConnectionManager: ObservableObject { // MARK: - 401 Recovery private func handleAuthenticationFailure() { - let isManaged = (try? GatewayHTTPClient.isConnectionManaged()) ?? false - if isManaged { + #if os(macOS) + let managedConnection = cachedAssistant?.isManaged ?? false + #else + let managedConnection = false + #endif + if managedConnection { log.warning("401 in managed mode — session token may be expired") eventStreamClient.broadcastMessage(.conversationError(ConversationErrorMessage( conversationId: "", @@ -704,7 +733,7 @@ public final class GatewayConnectionManager: ObservableObject { // Single CLI call replaces direct HTTP calls for broadcast + workspace commit Task { guard let handler = postSparkleUpdateHandler, - let name = LockfileAssistant.loadActiveAssistantId() else { return } + let name = cachedAssistant?.assistantId ?? LockfileAssistant.loadActiveAssistantId() else { return } await handler(name, preUpdateVersion) } } @@ -793,10 +822,27 @@ public final class GatewayConnectionManager: ObservableObject { } } + // MARK: - Cached Assistant + + #if os(macOS) + /// Synchronously refreshes the cached assistant snapshot from the lockfile. + /// Called during connect and when `activeAssistantDidChange` fires. + private func refreshCachedAssistant() { + guard let id = LockfileAssistant.loadActiveAssistantId() else { + cachedAssistant = nil + return + } + cachedAssistant = LockfileAssistant.loadByName(id) + } + #endif + deinit { #if os(macOS) autoWakeTask?.cancel() reconnectionTask?.cancel() + if let observer = assistantChangeObserver { + NotificationCenter.default.removeObserver(observer) + } #endif } } From 6aafc987ba29fd36816be1f30248bde5c9c4fdd6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:18:17 +0000 Subject: [PATCH 2/5] Fix iOS managed connection detection in performHealthCheck and handleAuthenticationFailure Keep GatewayHTTPClient.isConnectionManaged() in iOS #else branches to preserve managed connection detection via UserDefaults. The cache optimization is macOS-only since the lockfile I/O bottleneck only exists on macOS. Co-Authored-By: tkheyfets --- clients/shared/Network/GatewayConnectionManager.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/clients/shared/Network/GatewayConnectionManager.swift b/clients/shared/Network/GatewayConnectionManager.swift index 1c11a0eebf3..282f8745154 100644 --- a/clients/shared/Network/GatewayConnectionManager.swift +++ b/clients/shared/Network/GatewayConnectionManager.swift @@ -310,7 +310,7 @@ public final class GatewayConnectionManager: ObservableObject { #if os(macOS) healthPath = (cachedAssistant?.isManaged ?? false) ? "assistants/{assistantId}/health" : "health" #else - healthPath = "health" + healthPath = ((try? GatewayHTTPClient.isConnectionManaged()) ?? false) ? "assistants/{assistantId}/health" : "health" #endif let response = try await GatewayHTTPClient.get( path: healthPath, @@ -526,7 +526,7 @@ public final class GatewayConnectionManager: ObservableObject { #if os(macOS) let managedConnection = cachedAssistant?.isManaged ?? false #else - let managedConnection = false + let managedConnection = (try? GatewayHTTPClient.isConnectionManaged()) ?? false #endif if managedConnection { log.warning("401 in managed mode — session token may be expired") From 0c941715c96e8ccbfacaf255592b61f7af7a0ad2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 20:57:02 +0000 Subject: [PATCH 3/5] Enable lockfile watcher to detect cross-process assistant changes Co-Authored-By: tkheyfets --- clients/shared/Network/GatewayConnectionManager.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/clients/shared/Network/GatewayConnectionManager.swift b/clients/shared/Network/GatewayConnectionManager.swift index 282f8745154..22b6276151f 100644 --- a/clients/shared/Network/GatewayConnectionManager.swift +++ b/clients/shared/Network/GatewayConnectionManager.swift @@ -176,6 +176,7 @@ public final class GatewayConnectionManager: ObservableObject { #if os(macOS) refreshCachedAssistant() + LockfileAssistant.startWatching() #endif isConnecting = true @@ -843,6 +844,7 @@ public final class GatewayConnectionManager: ObservableObject { if let observer = assistantChangeObserver { NotificationCenter.default.removeObserver(observer) } + LockfileAssistant.stopWatching() #endif } } From b70aa85ebfaa52591c4d3d14ed84abcfba21ccf4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:09:00 +0000 Subject: [PATCH 4/5] Fix stale cache during reconfigure and add legacy connectedAssistantId fallback Co-Authored-By: tkheyfets --- .../shared/Network/GatewayConnectionManager.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/clients/shared/Network/GatewayConnectionManager.swift b/clients/shared/Network/GatewayConnectionManager.swift index 22b6276151f..632bd3e103e 100644 --- a/clients/shared/Network/GatewayConnectionManager.swift +++ b/clients/shared/Network/GatewayConnectionManager.swift @@ -273,13 +273,15 @@ public final class GatewayConnectionManager: ObservableObject { autoWakeTask?.cancel() autoWakeTask = nil #endif + #if os(macOS) + cachedAssistant = nil + #endif disconnect() isAuthenticated = false refreshTask?.cancel() refreshTask = nil #if os(macOS) lastAutoWakeAttempt = nil - cachedAssistant = nil #endif // Reset published state @@ -829,7 +831,15 @@ public final class GatewayConnectionManager: ObservableObject { /// Synchronously refreshes the cached assistant snapshot from the lockfile. /// Called during connect and when `activeAssistantDidChange` fires. private func refreshCachedAssistant() { - guard let id = LockfileAssistant.loadActiveAssistantId() else { + let id: String? + if let activeId = LockfileAssistant.loadActiveAssistantId(), !activeId.isEmpty { + id = activeId + } else if let legacyId = UserDefaults.standard.string(forKey: "connectedAssistantId"), !legacyId.isEmpty { + id = legacyId + } else { + id = nil + } + guard let id else { cachedAssistant = nil return } From 431fcdb1f11218407eca3795e290f1d557e2d5bb Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:17:38 +0000 Subject: [PATCH 5/5] Remove Task.detached from version write to preserve lockfile serialization Co-Authored-By: tkheyfets --- clients/shared/Network/GatewayConnectionManager.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/clients/shared/Network/GatewayConnectionManager.swift b/clients/shared/Network/GatewayConnectionManager.swift index 632bd3e103e..090a8ddd91c 100644 --- a/clients/shared/Network/GatewayConnectionManager.swift +++ b/clients/shared/Network/GatewayConnectionManager.swift @@ -333,10 +333,7 @@ public final class GatewayConnectionManager: ObservableObject { assistantVersion = newVersion #if os(macOS) if let id = cachedAssistant?.assistantId, !id.isEmpty { - let capturedId = id - Task.detached { - LockfilePaths.updateServiceGroupVersion(assistantId: capturedId, version: newVersion) - } + LockfilePaths.updateServiceGroupVersion(assistantId: id, version: newVersion) } #endif handleDaemonVersionChanged(newVersion)