← 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
M dashboard/src/lib/stores/app.svelte.tsM dashboard/src/routes/+page.svelteM src/exo/shared/apply.pyM src/exo/shared/types/profiling.pyM src/exo/utils/info_gatherer/info_gatherer.pyM src/exo/utils/info_gatherer/system_info.py
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 →