[object Object]

← back to Exo

feat: warn when cluster nodes have mismatched macOS versions (#1436)

a0721dbe572d3ea2c64c2e2090b0df9f20b5b7ad · 2026-02-11 13:18:59 -0800 · Alex Cheema

## Motivation

When nodes in an exo cluster run different macOS versions, inference can
produce incompatible results or fail silently. Users currently have no
way to know this from the dashboard.

## Changes

- Added `get_os_version()` to `system_info.py` that returns the macOS
version (e.g. `"15.3"`) or platform name for non-Mac nodes
- Added `os_version` field to `NodeIdentity` and
`StaticNodeInformation`, gathered once at startup
- Propagated `os_version` through the event sourcing pipeline
(`apply.py`)
- Exposed `nodeIdentities` from the dashboard store with `osVersion`
- Added a derived `macosVersionMismatch` check in `+page.svelte` that
triggers when 2+ macOS nodes report different versions
- Rendered a yellow "INCOMPATIBLE macOS VERSIONS" warning badge
(matching the existing Thunderbolt Bridge cycle warning style) with a
hover tooltip listing each node's name and version, in all three
topology view sizes (large, medium, compact)

## Why It Works

The OS version is a static property gathered once at node startup via
`platform.mac_ver()`. It flows through the existing
`StaticNodeInformation` → `NodeGatheredInfo` event → `NodeIdentity`
state pipeline, so no new event types or state fields beyond
`os_version` on `NodeIdentity` are needed. The dashboard derives the
mismatch by comparing `osVersion` across all nodes whose version looks
like a macOS version string (starts with a digit).

## Test Plan

### Manual Testing
Hardware: 4x Mac Studio M2 Ultra 512GB (s18, s17 (2), james, mike),
connected via Thunderbolt
- s18 and s17 (2) on macOS 26.2, james and mike on macOS 26.3
- Verified the "INCOMPATIBLE macOS VERSIONS" warning badge appears in
the topology view
- Verified the hover tooltip lists all four nodes with their respective
versions
- Screenshots attached in comment below

### Automated Testing
- basedpyright: 0 errors
- ruff check: all checks passed
- nix fmt: no formatting changes needed
- Dashboard builds successfully

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

Files touched

Diff

commit a0721dbe572d3ea2c64c2e2090b0df9f20b5b7ad
Author: Alex Cheema <41707476+AlexCheema@users.noreply.github.com>
Date:   Wed Feb 11 13:18:59 2026 -0800

    feat: warn when cluster nodes have mismatched macOS versions (#1436)
    
    ## Motivation
    
    When nodes in an exo cluster run different macOS versions, inference can
    produce incompatible results or fail silently. Users currently have no
    way to know this from the dashboard.
    
    ## Changes
    
    - Added `get_os_version()` to `system_info.py` that returns the macOS
    version (e.g. `"15.3"`) or platform name for non-Mac nodes
    - Added `os_version` field to `NodeIdentity` and
    `StaticNodeInformation`, gathered once at startup
    - Propagated `os_version` through the event sourcing pipeline
    (`apply.py`)
    - Exposed `nodeIdentities` from the dashboard store with `osVersion`
    - Added a derived `macosVersionMismatch` check in `+page.svelte` that
    triggers when 2+ macOS nodes report different versions
    - Rendered a yellow "INCOMPATIBLE macOS VERSIONS" warning badge
    (matching the existing Thunderbolt Bridge cycle warning style) with a
    hover tooltip listing each node's name and version, in all three
    topology view sizes (large, medium, compact)
    
    ## Why It Works
    
    The OS version is a static property gathered once at node startup via
    `platform.mac_ver()`. It flows through the existing
    `StaticNodeInformation` → `NodeGatheredInfo` event → `NodeIdentity`
    state pipeline, so no new event types or state fields beyond
    `os_version` on `NodeIdentity` are needed. The dashboard derives the
    mismatch by comparing `osVersion` across all nodes whose version looks
    like a macOS version string (starts with a digit).
    
    ## Test Plan
    
    ### Manual Testing
    Hardware: 4x Mac Studio M2 Ultra 512GB (s18, s17 (2), james, mike),
    connected via Thunderbolt
    - s18 and s17 (2) on macOS 26.2, james and mike on macOS 26.3
    - Verified the "INCOMPATIBLE macOS VERSIONS" warning badge appears in
    the topology view
    - Verified the hover tooltip lists all four nodes with their respective
    versions
    - Screenshots attached in comment below
    
    ### Automated Testing
    - basedpyright: 0 errors
    - ruff check: all checks passed
    - nix fmt: no formatting changes needed
    - Dashboard builds successfully
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
---
 dashboard/src/lib/stores/app.svelte.ts       |  10 +
 dashboard/src/routes/+page.svelte            | 478 ++++++++++++++++-----------
 src/exo/shared/apply.py                      |   7 +-
 src/exo/shared/types/profiling.py            |   2 +
 src/exo/utils/info_gatherer/info_gatherer.py |  34 +-
 src/exo/utils/info_gatherer/system_info.py   |  29 ++
 6 files changed, 353 insertions(+), 207 deletions(-)

diff --git a/dashboard/src/lib/stores/app.svelte.ts b/dashboard/src/lib/stores/app.svelte.ts
index d12d96e4..a444ada2 100644
--- a/dashboard/src/lib/stores/app.svelte.ts
+++ b/dashboard/src/lib/stores/app.svelte.ts
@@ -49,6 +49,7 @@ export interface NodeInfo {
   };
   last_macmon_update: number;
   friendly_name?: string;
+  os_version?: string;
 }
 
 export interface TopologyEdge {
@@ -78,6 +79,8 @@ interface RawNodeIdentity {
   modelId?: string;
   chipId?: string;
   friendlyName?: string;
+  osVersion?: string;
+  osBuildVersion?: string;
 }
 
 interface RawMemoryUsage {
@@ -440,6 +443,7 @@ function transformTopology(
       },
       last_macmon_update: Date.now() / 1000,
       friendly_name: identity?.friendlyName,
+      os_version: identity?.osVersion,
     };
   }
 
@@ -525,6 +529,7 @@ class AppStore {
   isLoadingPreviews = $state(false);
   previewNodeFilter = $state<Set<string>>(new Set());
   lastUpdate = $state<number | null>(null);
+  nodeIdentities = $state<Record<string, RawNodeIdentity>>({});
   thunderboltBridgeCycles = $state<string[][]>([]);
   nodeThunderbolt = $state<
     Record<
@@ -1249,6 +1254,8 @@ class AppStore {
       if (data.downloads) {
         this.downloads = data.downloads;
       }
+      // Node identities (for OS version mismatch detection)
+      this.nodeIdentities = data.nodeIdentities ?? {};
       // Thunderbolt identifiers per node
       this.nodeThunderbolt = data.nodeThunderbolt ?? {};
       // RDMA ctl status per node
@@ -3085,6 +3092,9 @@ export const setChatSidebarVisible = (visible: boolean) =>
   appStore.setChatSidebarVisible(visible);
 export const refreshState = () => appStore.fetchState();
 
+// Node identities (for OS version mismatch detection)
+export const nodeIdentities = () => appStore.nodeIdentities;
+
 // Thunderbolt & RDMA status
 export const nodeThunderbolt = () => appStore.nodeThunderbolt;
 export const nodeRdmaCtl = () => appStore.nodeRdmaCtl;
diff --git a/dashboard/src/routes/+page.svelte b/dashboard/src/routes/+page.svelte
index 920b0d79..3ada2386 100644
--- a/dashboard/src/routes/+page.svelte
+++ b/dashboard/src/routes/+page.svelte
@@ -46,6 +46,7 @@
     nodeRdmaCtl,
     thunderboltBridgeCycles,
     nodeThunderboltBridge,
+    nodeIdentities,
     type DownloadProgress,
     type PlacementPreview,
   } from "$lib/stores/app.svelte";
@@ -69,10 +70,34 @@
   const sidebarVisible = $derived(chatSidebarVisible());
   const tbBridgeCycles = $derived(thunderboltBridgeCycles());
   const tbBridgeData = $derived(nodeThunderboltBridge());
+  const identitiesData = $derived(nodeIdentities());
   const tbIdentifiers = $derived(nodeThunderbolt());
   const rdmaCtlData = $derived(nodeRdmaCtl());
   const nodeFilter = $derived(previewNodeFilter());
 
+  // Detect macOS version mismatches across cluster nodes
+  const macosVersionMismatch = $derived.by(() => {
+    if (!identitiesData) return null;
+    const entries = Object.entries(identitiesData);
+    // Filter to macOS nodes (version starts with a digit, e.g. "15.3")
+    const macosNodes = entries.filter(([_, id]) => {
+      const v = id.osVersion;
+      return v && v !== "Unknown" && /^\d/.test(v);
+    });
+    if (macosNodes.length < 2) return null;
+    // Compare on buildVersion for precise mismatch detection
+    const buildVersions = new Set(
+      macosNodes.map(([_, id]) => id.osBuildVersion ?? id.osVersion),
+    );
+    if (buildVersions.size <= 1) return null;
+    return macosNodes.map(([nodeId, id]) => ({
+      nodeId,
+      friendlyName: getNodeName(nodeId),
+      version: id.osVersion!,
+      buildVersion: id.osBuildVersion ?? "Unknown",
+    }));
+  });
+
   // Detect TB5 nodes where RDMA is not enabled
   const tb5WithoutRdma = $derived.by(() => {
     const rdmaCtl = rdmaCtlData;
@@ -121,6 +146,12 @@
     }
   }
 
+  // Warning icon SVG path (reused across warning snippets)
+  const warningIconPath =
+    "M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z";
+  const infoIconPath =
+    "M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z";
+
   let mounted = $state(false);
 
   // Instance launch state
@@ -1720,6 +1751,249 @@
   });
 </script>
 
+{#snippet clusterWarnings()}
+  {#if tbBridgeCycles.length > 0 || macosVersionMismatch || (tb5WithoutRdma && !tb5InfoDismissed)}
+    <div class="absolute top-4 left-4 flex flex-col gap-2 z-40">
+      {#if tbBridgeCycles.length > 0}
+        {@const cycle = tbBridgeCycles[0]}
+        {@const serviceName = getTbBridgeServiceName(cycle)}
+        {@const disableCmd = `sudo networksetup -setnetworkserviceenabled "${serviceName}" off`}
+        <div class="group relative" role="alert">
+          <div
+            class="flex items-center gap-2 px-3 py-2 rounded border border-yellow-500/50 bg-yellow-500/10 backdrop-blur-sm cursor-help"
+          >
+            <svg
+              class="w-5 h-5 text-yellow-400 flex-shrink-0"
+              fill="none"
+              viewBox="0 0 24 24"
+              stroke="currentColor"
+              stroke-width="2"
+            >
+              <path
+                stroke-linecap="round"
+                stroke-linejoin="round"
+                d={warningIconPath}
+              />
+            </svg>
+            <span class="text-sm font-mono text-yellow-200">
+              THUNDERBOLT BRIDGE CYCLE DETECTED
+            </span>
+          </div>
+
+          <!-- Tooltip on hover -->
+          <div
+            class="absolute top-full left-0 mt-2 w-80 p-3 rounded border border-yellow-500/30 bg-exo-dark-gray/95 backdrop-blur-sm opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50 shadow-lg"
+          >
+            <p class="text-xs text-white/80 mb-2">
+              A network routing cycle was detected between nodes connected via
+              Thunderbolt Bridge. This can cause connectivity issues.
+            </p>
+            <p class="text-xs text-white/60 mb-2">
+              <span class="text-yellow-300">Affected nodes:</span>
+              {cycle.map(getNodeName).join(" → ")}
+            </p>
+            <p class="text-xs text-white/60 mb-1">
+              <span class="text-yellow-300">To fix:</span> Disable the Thunderbolt
+              Bridge on one of the affected nodes:
+            </p>
+            <button
+              type="button"
+              onclick={() => copyToClipboard(disableCmd)}
+              class="w-full flex items-center gap-2 text-[10px] font-mono bg-exo-black/60 px-2 py-1.5 rounded text-exo-yellow break-all text-left hover:bg-exo-black/80 transition-colors cursor-pointer group/copy"
+              title="Click to copy"
+            >
+              <span class="flex-1">{disableCmd}</span>
+              <svg
+                class="w-3.5 h-3.5 flex-shrink-0 text-white/40 group-hover/copy:text-exo-yellow transition-colors"
+                fill="none"
+                viewBox="0 0 24 24"
+                stroke="currentColor"
+                stroke-width="2"
+              >
+                {#if copiedCommand}
+                  <path
+                    stroke-linecap="round"
+                    stroke-linejoin="round"
+                    d="M5 13l4 4L19 7"
+                  />
+                {:else}
+                  <path
+                    stroke-linecap="round"
+                    stroke-linejoin="round"
+                    d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
+                  />
+                {/if}
+              </svg>
+            </button>
+          </div>
+        </div>
+      {/if}
+
+      {#if macosVersionMismatch}
+        <div class="group relative" role="alert">
+          <div
+            class="flex items-center gap-2 px-3 py-2 rounded border border-yellow-500/50 bg-yellow-500/10 backdrop-blur-sm cursor-help"
+          >
+            <svg
+              class="w-5 h-5 text-yellow-400 flex-shrink-0"
+              fill="none"
+              viewBox="0 0 24 24"
+              stroke="currentColor"
+              stroke-width="2"
+            >
+              <path
+                stroke-linecap="round"
+                stroke-linejoin="round"
+                d={warningIconPath}
+              />
+            </svg>
+            <span class="text-sm font-mono text-yellow-200">
+              INCOMPATIBLE macOS VERSIONS
+            </span>
+          </div>
+
+          <!-- Tooltip on hover -->
+          <div
+            class="absolute top-full left-0 mt-2 w-80 p-3 rounded border border-yellow-500/30 bg-exo-dark-gray/95 backdrop-blur-sm opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50 shadow-lg"
+          >
+            <p class="text-xs text-white/80 mb-2">
+              Nodes in this cluster are running different macOS versions. This
+              may cause inference compatibility issues.
+            </p>
+            <div class="text-xs text-white/60 mb-2">
+              <span class="text-yellow-300">Node versions:</span>
+              {#each macosVersionMismatch as node}
+                <div class="ml-2">
+                  {node.friendlyName} — macOS {node.version} ({node.buildVersion})
+                </div>
+              {/each}
+            </div>
+            <p class="text-xs text-white/60">
+              <span class="text-yellow-300">Suggested action:</span> Update all nodes
+              to the same macOS version for best compatibility.
+            </p>
+          </div>
+        </div>
+      {/if}
+
+      {#if tb5WithoutRdma && !tb5InfoDismissed}
+        <div
+          class="flex items-center gap-2 px-3 py-2 rounded border border-blue-400/50 bg-blue-400/10 backdrop-blur-sm"
+          role="status"
+        >
+          <svg
+            class="w-5 h-5 text-blue-400 flex-shrink-0"
+            fill="none"
+            viewBox="0 0 24 24"
+            stroke="currentColor"
+            stroke-width="2"
+          >
+            <path
+              stroke-linecap="round"
+              stroke-linejoin="round"
+              d={infoIconPath}
+            />
+          </svg>
+          <span class="text-sm font-mono text-blue-200"> RDMA AVAILABLE </span>
+          <button
+            type="button"
+            onclick={() => (tb5InfoDismissed = true)}
+            class="ml-1 text-blue-300/60 hover:text-blue-200 transition-colors cursor-pointer"
+            title="Dismiss"
+          >
+            <svg
+              class="w-4 h-4"
+              fill="none"
+              viewBox="0 0 24 24"
+              stroke="currentColor"
+              stroke-width="2"
+            >
+              <path
+                stroke-linecap="round"
+                stroke-linejoin="round"
+                d="M6 18L18 6M6 6l12 12"
+              />
+            </svg>
+          </button>
+        </div>
+      {/if}
+    </div>
+  {/if}
+{/snippet}
+
+{#snippet clusterWarningsCompact()}
+  {#if tbBridgeCycles.length > 0 || macosVersionMismatch || (tb5WithoutRdma && !tb5InfoDismissed)}
+    <div class="absolute top-2 left-2 flex flex-col gap-1">
+      {#if tbBridgeCycles.length > 0}
+        <div
+          class="flex items-center gap-1.5 px-2 py-1 rounded border border-yellow-500/50 bg-yellow-500/10 backdrop-blur-sm"
+          title="Thunderbolt Bridge cycle detected"
+        >
+          <svg
+            class="w-3.5 h-3.5 text-yellow-400"
+            fill="none"
+            viewBox="0 0 24 24"
+            stroke="currentColor"
+            stroke-width="2"
+          >
+            <path
+              stroke-linecap="round"
+              stroke-linejoin="round"
+              d={warningIconPath}
+            />
+          </svg>
+          <span class="text-[10px] font-mono text-yellow-200">TB CYCLE</span>
+        </div>
+      {/if}
+      {#if macosVersionMismatch}
+        <div
+          class="flex items-center gap-1.5 px-2 py-1 rounded border border-yellow-500/50 bg-yellow-500/10 backdrop-blur-sm"
+          title="Incompatible macOS versions detected"
+        >
+          <svg
+            class="w-3.5 h-3.5 text-yellow-400"
+            fill="none"
+            viewBox="0 0 24 24"
+            stroke="currentColor"
+            stroke-width="2"
+          >
+            <path
+              stroke-linecap="round"
+              stroke-linejoin="round"
+              d={warningIconPath}
+            />
+          </svg>
+          <span class="text-[10px] font-mono text-yellow-200"
+            >macOS MISMATCH</span
+          >
+        </div>
+      {/if}
+      {#if tb5WithoutRdma && !tb5InfoDismissed}
+        <div
+          class="flex items-center gap-1.5 px-2 py-1 rounded border border-blue-400/50 bg-blue-400/10 backdrop-blur-sm"
+          title="Thunderbolt 5 detected — RDMA can be enabled for better performance"
+        >
+          <svg
+            class="w-3.5 h-3.5 text-blue-400"
+            fill="none"
+            viewBox="0 0 24 24"
+            stroke="currentColor"
+            stroke-width="2"
+          >
+            <path
+              stroke-linecap="round"
+              stroke-linejoin="round"
+              d={infoIconPath}
+            />
+          </svg>
+          <span class="text-[10px] font-mono text-blue-200">RDMA AVAILABLE</span
+          >
+        </div>
+      {/if}
+    </div>
+  {/if}
+{/snippet}
+
 <!-- Global event listeners for slider dragging -->
 <svelte:window
   onmousemove={handleSliderMouseMove}
@@ -1787,81 +2061,7 @@
             onNodeClick={togglePreviewNodeFilter}
           />
 
-          <!-- Thunderbolt Bridge Cycle Warning -->
-          {#if tbBridgeCycles.length > 0}
-            {@const cycle = tbBridgeCycles[0]}
-            {@const serviceName = getTbBridgeServiceName(cycle)}
-            {@const disableCmd = `sudo networksetup -setnetworkserviceenabled "${serviceName}" off`}
-            <div class="absolute top-4 left-4 group" role="alert">
-              <div
-                class="flex items-center gap-2 px-3 py-2 rounded border border-yellow-500/50 bg-yellow-500/10 backdrop-blur-sm cursor-help"
-              >
-                <svg
-                  class="w-5 h-5 text-yellow-400 flex-shrink-0"
-                  fill="none"
-                  viewBox="0 0 24 24"
-                  stroke="currentColor"
-                  stroke-width="2"
-                >
-                  <path
-                    stroke-linecap="round"
-                    stroke-linejoin="round"
-                    d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
-                  />
-                </svg>
-                <span class="text-sm font-mono text-yellow-200">
-                  THUNDERBOLT BRIDGE CYCLE DETECTED
-                </span>
-              </div>
-
-              <!-- Tooltip on hover -->
-              <div
-                class="absolute top-full left-0 mt-2 w-80 p-3 rounded border border-yellow-500/30 bg-exo-dark-gray/95 backdrop-blur-sm opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50 shadow-lg"
-              >
-                <p class="text-xs text-white/80 mb-2">
-                  A network routing cycle was detected between nodes connected
-                  via Thunderbolt Bridge. This can cause connectivity issues.
-                </p>
-                <p class="text-xs text-white/60 mb-2">
-                  <span class="text-yellow-300">Affected nodes:</span>
-                  {cycle.map(getNodeName).join(" → ")}
-                </p>
-                <p class="text-xs text-white/60 mb-1">
-                  <span class="text-yellow-300">To fix:</span> Disable the Thunderbolt
-                  Bridge on one of the affected nodes:
-                </p>
-                <button
-                  type="button"
-                  onclick={() => copyToClipboard(disableCmd)}
-                  class="w-full flex items-center gap-2 text-[10px] font-mono bg-exo-black/60 px-2 py-1.5 rounded text-exo-yellow break-all text-left hover:bg-exo-black/80 transition-colors cursor-pointer group/copy"
-                  title="Click to copy"
-                >
-                  <span class="flex-1">{disableCmd}</span>
-                  <svg
-                    class="w-3.5 h-3.5 flex-shrink-0 text-white/40 group-hover/copy:text-exo-yellow transition-colors"
-                    fill="none"
-                    viewBox="0 0 24 24"
-                    stroke="currentColor"
-                    stroke-width="2"
-                  >
-                    {#if copiedCommand}
-                      <path
-                        stroke-linecap="round"
-                        stroke-linejoin="round"
-                        d="M5 13l4 4L19 7"
-                      />
-                    {:else}
-                      <path
-                        stroke-linecap="round"
-                        stroke-linejoin="round"
-                        d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
-                      />
-                    {/if}
-                  </svg>
-                </button>
-              </div>
-            </div>
-          {/if}
+          {@render clusterWarnings()}
 
           <!-- TB5 RDMA Available Info -->
           {#if tb5WithoutRdma && !tb5InfoDismissed}
@@ -1953,81 +2153,7 @@
               onNodeClick={togglePreviewNodeFilter}
             />
 
-            <!-- Thunderbolt Bridge Cycle Warning -->
-            {#if tbBridgeCycles.length > 0}
-              {@const cycle = tbBridgeCycles[0]}
-              {@const serviceName = getTbBridgeServiceName(cycle)}
-              {@const disableCmd = `sudo networksetup -setnetworkserviceenabled "${serviceName}" off`}
-              <div class="absolute top-4 left-4 group" role="alert">
-                <div
-                  class="flex items-center gap-2 px-3 py-2 rounded border border-yellow-500/50 bg-yellow-500/10 backdrop-blur-sm cursor-help"
-                >
-                  <svg
-                    class="w-5 h-5 text-yellow-400 flex-shrink-0"
-                    fill="none"
-                    viewBox="0 0 24 24"
-                    stroke="currentColor"
-                    stroke-width="2"
-                  >
-                    <path
-                      stroke-linecap="round"
-                      stroke-linejoin="round"
-                      d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
-                    />
-                  </svg>
-                  <span class="text-sm font-mono text-yellow-200">
-                    THUNDERBOLT BRIDGE CYCLE DETECTED
-                  </span>
-                </div>
-
-                <!-- Tooltip on hover -->
-                <div
-                  class="absolute top-full left-0 mt-2 w-80 p-3 rounded border border-yellow-500/30 bg-exo-dark-gray/95 backdrop-blur-sm opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50 shadow-lg"
-                >
-                  <p class="text-xs text-white/80 mb-2">
-                    A network routing cycle was detected between nodes connected
-                    via Thunderbolt Bridge. This can cause connectivity issues.
-                  </p>
-                  <p class="text-xs text-white/60 mb-2">
-                    <span class="text-yellow-300">Affected nodes:</span>
-                    {cycle.map(getNodeName).join(" → ")}
-                  </p>
-                  <p class="text-xs text-white/60 mb-1">
-                    <span class="text-yellow-300">To fix:</span> Disable the Thunderbolt
-                    Bridge on one of the affected nodes:
-                  </p>
-                  <button
-                    type="button"
-                    onclick={() => copyToClipboard(disableCmd)}
-                    class="w-full flex items-center gap-2 text-[10px] font-mono bg-exo-black/60 px-2 py-1.5 rounded text-exo-yellow break-all text-left hover:bg-exo-black/80 transition-colors cursor-pointer group/copy"
-                    title="Click to copy"
-                  >
-                    <span class="flex-1">{disableCmd}</span>
-                    <svg
-                      class="w-3.5 h-3.5 flex-shrink-0 text-white/40 group-hover/copy:text-exo-yellow transition-colors"
-                      fill="none"
-                      viewBox="0 0 24 24"
-                      stroke="currentColor"
-                      stroke-width="2"
-                    >
-                      {#if copiedCommand}
-                        <path
-                          stroke-linecap="round"
-                          stroke-linejoin="round"
-                          d="M5 13l4 4L19 7"
-                        />
-                      {:else}
-                        <path
-                          stroke-linecap="round"
-                          stroke-linejoin="round"
-                          d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
-                        />
-                      {/if}
-                    </svg>
-                  </button>
-                </div>
-              </div>
-            {/if}
+            {@render clusterWarnings()}
 
             <!-- TB5 RDMA Available Info -->
             {#if tb5WithoutRdma && !tb5InfoDismissed}
@@ -2963,57 +3089,7 @@
                   onNodeClick={togglePreviewNodeFilter}
                 />
 
-                <!-- Thunderbolt Bridge Cycle Warning (compact) -->
-                {#if tbBridgeCycles.length > 0}
-                  <div
-                    class="absolute top-2 left-2 flex items-center gap-1.5 px-2 py-1 rounded border border-yellow-500/50 bg-yellow-500/10 backdrop-blur-sm"
-                    title="Thunderbolt Bridge cycle detected - click to view details"
-                  >
-                    <svg
-                      class="w-3.5 h-3.5 text-yellow-400"
-                      fill="none"
-                      viewBox="0 0 24 24"
-                      stroke="currentColor"
-                      stroke-width="2"
-                    >
-                      <path
-                        stroke-linecap="round"
-                        stroke-linejoin="round"
-                        d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
-                      />
-                    </svg>
-                    <span class="text-[10px] font-mono text-yellow-200"
-                      >TB CYCLE</span
-                    >
-                  </div>
-                {/if}
-
-                <!-- TB5 RDMA Available (compact) -->
-                {#if tb5WithoutRdma && !tb5InfoDismissed}
-                  <div
-                    class="absolute left-2 flex items-center gap-1.5 px-2 py-1 rounded border border-blue-400/50 bg-blue-400/10 backdrop-blur-sm"
-                    class:top-10={tbBridgeCycles.length > 0}
-                    class:top-2={tbBridgeCycles.length === 0}
-                    title="Thunderbolt 5 detected — RDMA can be enabled for better performance"
-                  >
-                    <svg
-                      class="w-3.5 h-3.5 text-blue-400"
-                      fill="none"
-                      viewBox="0 0 24 24"
-                      stroke="currentColor"
-                      stroke-width="2"
-                    >
-                      <path
-                        stroke-linecap="round"
-                        stroke-linejoin="round"
-                        d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
-                      />
-                    </svg>
-                    <span class="text-[10px] font-mono text-blue-200"
-                      >RDMA AVAILABLE</span
-                    >
-                  </div>
-                {/if}
+                {@render clusterWarningsCompact()}
               </div>
             </button>
 
diff --git a/src/exo/shared/apply.py b/src/exo/shared/apply.py
index 32801172..541a53d0 100644
--- a/src/exo/shared/apply.py
+++ b/src/exo/shared/apply.py
@@ -308,7 +308,12 @@ def apply_node_gathered_info(event: NodeGatheredInfo, state: State) -> State:
         case StaticNodeInformation():
             current_identity = state.node_identities.get(event.node_id, NodeIdentity())
             new_identity = current_identity.model_copy(
-                update={"model_id": info.model, "chip_id": info.chip}
+                update={
+                    "model_id": info.model,
+                    "chip_id": info.chip,
+                    "os_version": info.os_version,
+                    "os_build_version": info.os_build_version,
+                }
             )
             update["node_identities"] = {
                 **state.node_identities,
diff --git a/src/exo/shared/types/profiling.py b/src/exo/shared/types/profiling.py
index 0b325ef5..b5e4040e 100644
--- a/src/exo/shared/types/profiling.py
+++ b/src/exo/shared/types/profiling.py
@@ -63,6 +63,8 @@ class NodeIdentity(CamelCaseModel):
     model_id: str = "Unknown"
     chip_id: str = "Unknown"
     friendly_name: str = "Unknown"
+    os_version: str = "Unknown"
+    os_build_version: str = "Unknown"
 
 
 class NodeNetworkInfo(CamelCaseModel):
diff --git a/src/exo/utils/info_gatherer/info_gatherer.py b/src/exo/utils/info_gatherer/info_gatherer.py
index 53f1a392..68dab6f9 100644
--- a/src/exo/utils/info_gatherer/info_gatherer.py
+++ b/src/exo/utils/info_gatherer/info_gatherer.py
@@ -8,7 +8,7 @@ from subprocess import CalledProcessError
 from typing import Self, cast
 
 import anyio
-from anyio import create_task_group, open_process
+from anyio import create_task_group, fail_after, open_process
 from anyio.abc import TaskGroup
 from anyio.streams.buffered import BufferedByteReceiveStream
 from anyio.streams.text import TextReceiveStream
@@ -31,7 +31,13 @@ from exo.utils.channels import Sender
 from exo.utils.pydantic_ext import TaggedModel
 
 from .macmon import MacmonMetrics
-from .system_info import get_friendly_name, get_model_and_chip, get_network_interfaces
+from .system_info import (
+    get_friendly_name,
+    get_model_and_chip,
+    get_network_interfaces,
+    get_os_build_version,
+    get_os_version,
+)
 
 IS_DARWIN = sys.platform == "darwin"
 
@@ -177,11 +183,18 @@ class StaticNodeInformation(TaggedModel):
 
     model: str
     chip: str
+    os_version: str
+    os_build_version: str
 
     @classmethod
     async def gather(cls) -> Self:
         model, chip = await get_model_and_chip()
-        return cls(model=model, chip=chip)
+        return cls(
+            model=model,
+            chip=chip,
+            os_version=get_os_version(),
+            os_build_version=await get_os_build_version(),
+        )
 
 
 class NodeNetworkInterfaces(TaggedModel):
@@ -349,6 +362,7 @@ class InfoGatherer:
     memory_poll_rate: float | None = None if IS_DARWIN else 1
     macmon_interval: float | None = 1 if IS_DARWIN else None
     thunderbolt_bridge_poll_interval: float | None = 10 if IS_DARWIN else None
+    static_info_poll_interval: float | None = 60
     rdma_ctl_poll_interval: float | None = 10 if IS_DARWIN else None
     _tg: TaskGroup = field(init=False, default_factory=create_task_group)
 
@@ -363,16 +377,26 @@ class InfoGatherer:
             tg.start_soon(self._watch_system_info)
             tg.start_soon(self._monitor_memory_usage)
             tg.start_soon(self._monitor_misc)
+            tg.start_soon(self._monitor_static_info)
 
             nc = await NodeConfig.gather()
             if nc is not None:
                 await self.info_sender.send(nc)
-            sni = await StaticNodeInformation.gather()
-            await self.info_sender.send(sni)
 
     def shutdown(self):
         self._tg.cancel_scope.cancel()
 
+    async def _monitor_static_info(self):
+        if self.static_info_poll_interval is None:
+            return
+        while True:
+            try:
+                with fail_after(30):
+                    await self.info_sender.send(await StaticNodeInformation.gather())
+            except Exception as e:
+                logger.warning(f"Error gathering static node info: {e}")
+            await anyio.sleep(self.static_info_poll_interval)
+
     async def _monitor_misc(self):
         if self.misc_poll_interval is None:
             return
diff --git a/src/exo/utils/info_gatherer/system_info.py b/src/exo/utils/info_gatherer/system_info.py
index e1c451a9..0cc13f9c 100644
--- a/src/exo/utils/info_gatherer/system_info.py
+++ b/src/exo/utils/info_gatherer/system_info.py
@@ -1,3 +1,4 @@
+import platform
 import socket
 import sys
 from subprocess import CalledProcessError
@@ -8,6 +9,34 @@ from anyio import run_process
 from exo.shared.types.profiling import InterfaceType, NetworkInterfaceInfo
 
 
+def get_os_version() -> str:
+    """Return the OS version string for this node.
+
+    On macOS this is the macOS version (e.g. ``"15.3"``).
+    On other platforms it falls back to the platform name (e.g. ``"Linux"``).
+    """
+    if sys.platform == "darwin":
+        version = platform.mac_ver()[0]
+        return version if version else "Unknown"
+    return platform.system() or "Unknown"
+
+
+async def get_os_build_version() -> str:
+    """Return the macOS build version string (e.g. ``"24D5055b"``).
+
+    On non-macOS platforms, returns ``"Unknown"``.
+    """
+    if sys.platform != "darwin":
+        return "Unknown"
+
+    try:
+        process = await run_process(["sw_vers", "-buildVersion"])
+    except CalledProcessError:
+        return "Unknown"
+
+    return process.stdout.decode("utf-8", errors="replace").strip() or "Unknown"
+
+
 async def get_friendly_name() -> str:
     """
     Asynchronously gets the 'Computer Name' (friendly name) of a Mac.

← 50e2bcf9 fix: RDMA debug labels, TB5 info box, and rdma_ctl status de  ·  back to Exo  ·  dashboard: show available disk space on downloads page a8acb3ca →