Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 70 additions & 15 deletions clients/shared/Network/GatewayConnectionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -161,6 +174,11 @@ public final class GatewayConnectionManager: ObservableObject {
#endif
disconnectInternal(cancelAutoWake: cancelAutoWake)

#if os(macOS)
refreshCachedAssistant()
LockfileAssistant.startWatching()
#endif

isConnecting = true

if let conversationKey, !conversationKey.isEmpty {
Expand Down Expand Up @@ -255,6 +273,9 @@ public final class GatewayConnectionManager: ObservableObject {
autoWakeTask?.cancel()
autoWakeTask = nil
#endif
#if os(macOS)
cachedAssistant = nil
#endif
disconnect()
isAuthenticated = false
refreshTask?.cancel()
Expand Down Expand Up @@ -288,8 +309,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"
Comment thread
tkheyfets marked this conversation as resolved.
Comment thread
tkheyfets marked this conversation as resolved.
Comment thread
tkheyfets marked this conversation as resolved.
#else
Comment thread
tkheyfets marked this conversation as resolved.
healthPath = ((try? GatewayHTTPClient.isConnectionManaged()) ?? false) ? "assistants/{assistantId}/health" : "health"
#endif
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
let response = try await GatewayHTTPClient.get(
path: healthPath,
timeout: 10,
Expand All @@ -307,7 +332,7 @@ public final class GatewayConnectionManager: ObservableObject {
if let newVersion = decoded.version, newVersion != assistantVersion {
assistantVersion = newVersion
#if os(macOS)
if let id = LockfileAssistant.loadActiveAssistantId(), !id.isEmpty {
if let id = cachedAssistant?.assistantId, !id.isEmpty {
LockfilePaths.updateServiceGroupVersion(assistantId: id, version: newVersion)
}
#endif
Expand Down Expand Up @@ -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 = (try? GatewayHTTPClient.isConnectionManaged()) ?? false
#endif
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
if managedConnection {
log.warning("401 in managed mode — session token may be expired")
eventStreamClient.broadcastMessage(.conversationError(ConversationErrorMessage(
conversationId: "",
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -793,10 +822,36 @@ 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() {
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
Comment thread
tkheyfets marked this conversation as resolved.
} else {
id = nil
}
guard let id 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)
}
LockfileAssistant.stopWatching()
#endif
}
}
Expand Down