From f13b953a826f8f1524d51c00eb2008248a7177bf Mon Sep 17 00:00:00 2001 From: IT-BillDeng Date: Mon, 27 Oct 2025 02:29:51 +0800 Subject: [PATCH 1/3] Enable UI toggle for master participation with live status feedback and delegate-mode orchestration. --- distributed.py | 81 +++++++++++++++++++++++++------- utils/config.py | 12 ++++- web/executionUtils.js | 107 ++++++++++++++++++++++++++++++++++++++++-- web/main.js | 38 +++++++++++++-- web/ui.js | 55 ++++++++++++++++------ web/workerUtils.js | 41 ++++++++++++++++ 6 files changed, 293 insertions(+), 41 deletions(-) diff --git a/distributed.py b/distributed.py index 1c2cf57..cdc699d 100644 --- a/distributed.py +++ b/distributed.py @@ -23,7 +23,15 @@ from comfy.utils import ProgressBar # Import shared utilities from .utils.logging import debug_log, log -from .utils.config import CONFIG_FILE, get_default_config, load_config, save_config, ensure_config_exists, get_worker_timeout_seconds +from .utils.config import ( + CONFIG_FILE, + get_default_config, + load_config, + save_config, + ensure_config_exists, + get_worker_timeout_seconds, + is_master_delegate_only, +) from .utils.image import tensor_to_pil, pil_to_tensor, ensure_contiguous from .utils.process import is_process_alive, terminate_process, get_python_executable from .utils.network import handle_api_error, get_server_port, get_server_loop, get_client_session, cleanup_client_session @@ -1813,6 +1821,7 @@ class DistributedCollectorNode: "worker_batch_size": ("INT", {"default": 1, "min": 1, "max": 1024}), "worker_id": ("STRING", {"default": ""}), "pass_through": ("BOOLEAN", {"default": False}), + "delegate_only": ("BOOLEAN", {"default": False}), }, } @@ -1820,7 +1829,7 @@ class DistributedCollectorNode: FUNCTION = "run" CATEGORY = "image" - def run(self, images, multi_job_id="", is_worker=False, master_url="", enabled_worker_ids="[]", worker_batch_size=1, worker_id="", pass_through=False): + def run(self, images, multi_job_id="", is_worker=False, master_url="", enabled_worker_ids="[]", worker_batch_size=1, worker_id="", pass_through=False, delegate_only=False): if not multi_job_id or pass_through: if pass_through: print(f"[Distributed Collector] Pass-through mode enabled, returning images unchanged") @@ -1828,7 +1837,7 @@ class DistributedCollectorNode: # Use async helper to run in server loop result = run_async_in_server_loop( - self.execute(images, multi_job_id, is_worker, master_url, enabled_worker_ids, worker_batch_size, worker_id) + self.execute(images, multi_job_id, is_worker, master_url, enabled_worker_ids, worker_batch_size, worker_id, delegate_only) ) return result @@ -1876,25 +1885,31 @@ class DistributedCollectorNode: raise # Re-raise to handle at caller level - async def execute(self, images, multi_job_id="", is_worker=False, master_url="", enabled_worker_ids="[]", worker_batch_size=1, worker_id=""): + async def execute(self, images, multi_job_id="", is_worker=False, master_url="", enabled_worker_ids="[]", worker_batch_size=1, worker_id="", delegate_only=False): if is_worker: # Worker mode: send images to master in a single batch debug_log(f"Worker - Job {multi_job_id} complete. Sending {images.shape[0]} image(s) to master") await self.send_batch_to_master(images, multi_job_id, master_url, worker_id) return (images,) else: + delegate_mode = delegate_only or is_master_delegate_only() # Master mode: collect images from workers enabled_workers = json.loads(enabled_worker_ids) num_workers = len(enabled_workers) if num_workers == 0: return (images,) - images_on_cpu = images.cpu() - master_batch_size = images.shape[0] - debug_log(f"Master - Job {multi_job_id}: Master has {master_batch_size} images, collecting from {num_workers} workers...") - - # Ensure master images are contiguous - images_on_cpu = ensure_contiguous(images_on_cpu) + if delegate_mode: + master_batch_size = 0 + images_on_cpu = None + debug_log(f"Master - Job {multi_job_id}: Delegate-only mode enabled, collecting exclusively from {num_workers} workers") + else: + images_on_cpu = images.cpu() + master_batch_size = images.shape[0] + debug_log(f"Master - Job {multi_job_id}: Master has {master_batch_size} images, collecting from {num_workers} workers...") + + # Ensure master images are contiguous + images_on_cpu = ensure_contiguous(images_on_cpu) # Initialize storage for collected images @@ -2101,9 +2116,10 @@ class DistributedCollectorNode: # Pattern: master img 1, master img 2, worker 1 img 1, worker 1 img 2, worker 2 img 1, worker 2 img 2, etc. ordered_tensors = [] - # Add master images first - for i in range(master_batch_size): - ordered_tensors.append(images_on_cpu[i:i+1]) + # Add master images first (if any) + if not delegate_mode and images_on_cpu is not None: + for i in range(master_batch_size): + ordered_tensors.append(images_on_cpu[i:i+1]) # Add worker images in order # The worker IDs in worker_images are already strings (e.g., "1", "2") @@ -2123,7 +2139,13 @@ class DistributedCollectorNode: cpu_tensors.append(t) try: - combined = torch.cat(cpu_tensors, dim=0) + if cpu_tensors: + combined = torch.cat(cpu_tensors, dim=0) + else: + # No tensors collected (likely delegate mode with no worker output) + combined = ensure_contiguous(images) if images is not None else images + if combined is None: + raise ValueError("No image data collected from master or workers") # Ensure the combined tensor is contiguous and properly formatted combined = ensure_contiguous(combined) debug_log(f"Master - Job {multi_job_id} complete. Combined {combined.shape[0]} images total (master: {master_batch_size}, workers: {combined.shape[0] - master_batch_size})") @@ -2262,13 +2284,40 @@ class ImageBatchDivider: return tuple(outputs) +class DistributedEmptyImage: + """Produces an empty IMAGE batch used when the master delegates all work.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "height": ("INT", {"default": 64, "min": 1, "max": 4096, "step": 1}), + "width": ("INT", {"default": 64, "min": 1, "max": 4096, "step": 1}), + "channels": ("INT", {"default": 3, "min": 1, "max": 4, "step": 1}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "create" + CATEGORY = "image" + + def create(self, height, width, channels): + import torch + + shape = (0, height, width, channels) + tensor = torch.zeros(shape, dtype=torch.float32) + return (tensor,) + + NODE_CLASS_MAPPINGS = { "DistributedCollector": DistributedCollectorNode, "DistributedSeed": DistributedSeed, - "ImageBatchDivider": ImageBatchDivider + "ImageBatchDivider": ImageBatchDivider, + "DistributedEmptyImage": DistributedEmptyImage, } NODE_DISPLAY_NAME_MAPPINGS = { "DistributedCollector": "Distributed Collector", "DistributedSeed": "Distributed Seed", - "ImageBatchDivider": "Image Batch Divider" + "ImageBatchDivider": "Image Batch Divider", + "DistributedEmptyImage": "Distributed Empty Image", } diff --git a/utils/config.py b/utils/config.py index 2569663..8c9aa49 100644 --- a/utils/config.py +++ b/utils/config.py @@ -18,7 +18,8 @@ def get_default_config(): "settings": { "debug": False, "auto_launch_workers": False, - "stop_workers_on_master_exit": True + "stop_workers_on_master_exit": True, + "master_delegate_only": False } } @@ -69,3 +70,12 @@ def get_worker_timeout_seconds(default: int = HEARTBEAT_TIMEOUT) -> int: return max(1, val) except Exception: return max(1, int(default)) + + +def is_master_delegate_only() -> bool: + """Returns True when master should skip local workload and act as orchestrator only.""" + try: + cfg = load_config() + return bool(cfg.get('settings', {}).get('master_delegate_only', False)) + except Exception: + return False diff --git a/web/executionUtils.js b/web/executionUtils.js index 9faea12..453e2f6 100644 --- a/web/executionUtils.js +++ b/web/executionUtils.js @@ -1,5 +1,5 @@ import { api } from "../../scripts/api.js"; -import { findNodesByClass, findImageReferences, hasUpstreamNode, pruneWorkflowForWorker, getCachedWorkerSystemInfo } from './workerUtils.js'; +import { findNodesByClass, findImageReferences, hasUpstreamNode, pruneWorkflowForWorker, getCachedWorkerSystemInfo, findCollectorDownstreamNodes } from './workerUtils.js'; import { TIMEOUTS } from './constants.js'; /** @@ -131,6 +131,19 @@ export async function executeParallelDistributed(extension, promptWrapper) { const collectorNodes = findNodesByClass(promptWrapper.output, "DistributedCollector"); const upscaleNodes = findNodesByClass(promptWrapper.output, "UltimateSDUpscaleDistributed"); const allDistributedNodes = [...collectorNodes, ...upscaleNodes]; + + const wantsDelegate = Boolean(extension.config?.settings?.master_delegate_only); + let masterDelegateActive = false; + if (wantsDelegate) { + if (upscaleNodes.length > 0) { + extension.log("Master delegate-only mode is not yet supported for UltimateSDUpscaleDistributed nodes. Falling back to normal execution.", "warn"); + } else if (collectorNodes.length === 0) { + extension.log("Master delegate-only mode requested but no DistributedCollector nodes found. Falling back to normal execution.", "debug"); + } else { + masterDelegateActive = true; + extension.log("Master delegate-only mode enabled: master will orchestrate without running upstream nodes.", "debug"); + } + } // Map original node IDs to truly unique job IDs for this specific run const job_id_map = new Map(allDistributedNodes.map(node => [node.id, `${executionPrefix}_${node.id}`])); @@ -147,7 +160,8 @@ export async function executeParallelDistributed(extension, promptWrapper) { const options = { enabled_worker_ids: activeWorkers.map(w => w.id), workflow: promptWrapper.workflow, - job_id_map: job_id_map // Pass the map of unique IDs + job_id_map: job_id_map, // Pass the map of unique IDs + delegate_master: masterDelegateActive && participantId === 'master' }; const jobApiPrompt = await prepareApiPromptForParticipant( @@ -187,11 +201,24 @@ export async function executeParallelDistributed(extension, promptWrapper) { export async function prepareApiPromptForParticipant(extension, baseApiPrompt, participantId, options = {}) { let jobApiPrompt = JSON.parse(JSON.stringify(baseApiPrompt)); const isMaster = participantId === 'master'; + const delegateMaster = Boolean(options.delegate_master); // Find all distributed nodes once (before pruning) - const collectorNodes = findNodesByClass(jobApiPrompt, "DistributedCollector"); + let collectorNodes = findNodesByClass(jobApiPrompt, "DistributedCollector"); const upscaleNodes = findNodesByClass(jobApiPrompt, "UltimateSDUpscaleDistributed"); - const allDistributedNodes = [...collectorNodes, ...upscaleNodes]; + let allDistributedNodes = [...collectorNodes, ...upscaleNodes]; + + if (isMaster && delegateMaster) { + if (upscaleNodes.length > 0) { + extension.log("Delegate-only master mode does not support UltimateSDUpscaleDistributed nodes yet. Using full prompt.", "warn"); + } else if (collectorNodes.length === 0) { + extension.log("Delegate-only master mode requested but no collectors found in master prompt. Using full prompt.", "debug"); + } else { + jobApiPrompt = prepareMasterDelegatePrompt(extension, jobApiPrompt, collectorNodes); + collectorNodes = findNodesByClass(jobApiPrompt, "DistributedCollector"); + allDistributedNodes = [...collectorNodes, ...upscaleNodes]; + } + } // For workers, handle platform-specific path conversion if (!isMaster) { @@ -282,6 +309,9 @@ export async function prepareApiPromptForParticipant(extension, baseApiPrompt, p inputs.is_worker = !isMaster; if (isMaster) { inputs.enabled_worker_ids = JSON.stringify(options.enabled_worker_ids || []); + if (delegateMaster) { + inputs.delegate_only = true; + } } else { inputs.master_url = extension.getMasterUrl(); // Also make the worker_job_id unique to prevent potential caching issues @@ -323,6 +353,75 @@ export async function prepareDistributedJob(extension, multi_job_id) { } } +function createNumericIdGenerator(promptObj) { + let maxId = 0; + for (const key of Object.keys(promptObj)) { + const numeric = parseInt(key, 10); + if (!Number.isNaN(numeric)) { + maxId = Math.max(maxId, numeric); + } + } + return () => { + maxId += 1; + return String(maxId); + }; +} + +function prepareMasterDelegatePrompt(extension, apiPrompt, collectorNodes) { + const collectorIds = collectorNodes.map((node) => node.id); + const nodesToKeep = findCollectorDownstreamNodes(apiPrompt, collectorIds); + + // Ensure collectors themselves are present + collectorIds.forEach((id) => nodesToKeep.add(id)); + + const prunedPrompt = {}; + nodesToKeep.forEach((nodeId) => { + const nodeData = apiPrompt[nodeId]; + if (nodeData) { + prunedPrompt[nodeId] = JSON.parse(JSON.stringify(nodeData)); + } + }); + + const prunedIds = new Set(Object.keys(prunedPrompt)); + + // Remove dangling references to trimmed nodes + for (const [nodeId, node] of Object.entries(prunedPrompt)) { + if (!node.inputs) continue; + for (const [inputName, inputValue] of Object.entries(node.inputs)) { + if (Array.isArray(inputValue) && inputValue.length === 2) { + const sourceId = String(inputValue[0]); + if (!prunedIds.has(sourceId)) { + delete node.inputs[inputName]; + extension.log(`Removed upstream reference '${inputName}' from node ${nodeId} for delegate-only master prompt.`, "debug"); + } + } + } + } + + const nextId = createNumericIdGenerator(prunedPrompt); + collectorIds.forEach((collectorId) => { + const collectorEntry = prunedPrompt[collectorId]; + if (!collectorEntry) return; + + const placeholderId = nextId(); + prunedPrompt[placeholderId] = { + class_type: "DistributedEmptyImage", + inputs: { + height: 64, + width: 64, + channels: 3 + } + }; + prunedIds.add(placeholderId); + + collectorEntry.inputs = collectorEntry.inputs || {}; + collectorEntry.inputs.images = [placeholderId, 0]; + }); + + extension.log(`Prepared delegate-only master prompt with ${Object.keys(prunedPrompt).length} nodes (kept ${nodesToKeep.size}).`, "debug"); + return prunedPrompt; +} + export async function executeJobs(extension, jobs) { let masterPromptId = null; diff --git a/web/main.js b/web/main.js index 1f0b007..0e3934c 100644 --- a/web/main.js +++ b/web/main.js @@ -90,6 +90,26 @@ class DistributedExtension { return this.enabledWorkers.length > 0; } + isMasterParticipating() { + return !(this.config?.settings?.master_delegate_only); + } + + async updateMasterParticipation(enabled) { + if (!this.config?.settings) { + this.config.settings = {}; + } + const delegateOnly = !enabled; + if (this.config.settings.master_delegate_only === delegateOnly) { + return; + } + + await this._updateSetting('master_delegate_only', delegateOnly); + + if (this.panelElement) { + renderSidebarContent(this, this.panelElement); + } + } + async loadConfig() { try { this.config = await this.api.getConfig(); @@ -300,11 +320,19 @@ class DistributedExtension { // Update master status dot const statusDot = document.getElementById('master-status'); if (statusDot) { - if (isProcessing) { - statusDot.style.backgroundColor = "#f0ad4e"; + if (!this.isMasterParticipating()) { + if (isProcessing) { + statusDot.style.backgroundColor = STATUS_COLORS.PROCESSING_YELLOW; + statusDot.title = `Orchestrating (${queueRemaining} in queue)`; + } else { + statusDot.style.backgroundColor = STATUS_COLORS.DISABLED_GRAY; + statusDot.title = "Master orchestrator only"; + } + } else if (isProcessing) { + statusDot.style.backgroundColor = STATUS_COLORS.PROCESSING_YELLOW; statusDot.title = `Processing (${queueRemaining} in queue)`; } else { - statusDot.style.backgroundColor = "#4CAF50"; + statusDot.style.backgroundColor = STATUS_COLORS.ONLINE_GREEN; statusDot.title = "Online"; } } @@ -313,8 +341,8 @@ class DistributedExtension { // Master is always online (we're running on it), so keep it green const statusDot = document.getElementById('master-status'); if (statusDot) { - statusDot.style.backgroundColor = "#4CAF50"; - statusDot.title = "Online"; + statusDot.style.backgroundColor = this.isMasterParticipating() ? STATUS_COLORS.ONLINE_GREEN : STATUS_COLORS.DISABLED_GRAY; + statusDot.title = this.isMasterParticipating() ? "Online" : "Master orchestrator only"; } } } diff --git a/web/ui.js b/web/ui.js index 46fbe73..5444b7d 100644 --- a/web/ui.js +++ b/web/ui.js @@ -3,28 +3,25 @@ import { BUTTON_STYLES, UI_STYLES, STATUS_COLORS, UI_COLORS, TIMEOUTS } from './ const cardConfigs = { master: { checkbox: { - enabled: false, - checked: true, - disabled: true, - opacity: 0.6, - title: "Master node is always enabled" + enabled: true, + masterToggle: true, + title: "Toggle master participation in workloads" }, statusDot: { - color: STATUS_COLORS.ONLINE_GREEN, - title: 'Online', id: 'master-status', + initialColor: (_, extension) => extension.isMasterParticipating() ? STATUS_COLORS.ONLINE_GREEN : STATUS_COLORS.DISABLED_GRAY, + initialTitle: (_, extension) => extension.isMasterParticipating() ? 'Master participating' : 'Master orchestrator only', dynamic: true }, infoText: (data, extension) => { const cudaDevice = extension.config?.master?.cuda_device ?? extension.masterCudaDevice; const cudaInfo = cudaDevice !== undefined ? `CUDA ${cudaDevice} • ` : ''; const port = window.location.port || (window.location.protocol === 'https:' ? '443' : '80'); - return `${data?.name || extension.config?.master?.name || "Master"}
${cudaInfo}Port ${port}`; + const delegateBadge = extension.isMasterParticipating() ? '' : `
Orchestrator-only mode`; + return `${data?.name || extension.config?.master?.name || "Master"}
${cudaInfo}Port ${port}${delegateBadge}`; }, controls: { - type: 'info', - text: 'Master', - style: "background-color: #333; color: #999;" + type: 'master' }, settings: { formType: 'master', @@ -868,7 +865,24 @@ export class DistributedUI { if (config?.opacity) checkbox.style.opacity = config.opacity; if (config?.title) column.title = config.title; - if (config?.enabled && !config?.disabled && data?.id) { + const isMasterToggle = config?.masterToggle && typeof extension.isMasterParticipating === 'function'; + if (isMasterToggle) { + const participating = extension.isMasterParticipating(); + checkbox.checked = participating; + checkbox.style.pointerEvents = "none"; + column.style.cursor = "pointer"; + column.title = participating ? "Master participating • Click to switch to orchestrator-only" : "Master orchestrator-only • Click to re-enable participation"; + column.onclick = async (event) => { + if (event) { + event.stopPropagation(); + event.preventDefault(); + } + const targetState = !checkbox.checked; + checkbox.checked = targetState; + column.title = targetState ? "Master participating • Click to switch to orchestrator-only" : "Master orchestrator-only • Click to re-enable participation"; + await extension.updateMasterParticipation(targetState); + }; + } else if (config?.enabled && !config?.disabled && data?.id) { checkbox.style.pointerEvents = "none"; column.style.cursor = "pointer"; column.onclick = async () => { @@ -890,10 +904,10 @@ export class DistributedUI { let id = config.id; if (typeof config.initialColor === 'function') { - color = config.initialColor(data); + color = config.initialColor(data, extension); } if (typeof config.initialTitle === 'function') { - title = config.initialTitle(data); + title = config.initialTitle(data, extension); } if (typeof config.id === 'function') { id = config.id(data); @@ -940,7 +954,18 @@ export class DistributedUI { const controlsWrapper = document.createElement("div"); controlsWrapper.style.cssText = this.styles.controlsWrapper; - if (config.dynamic && data) { + if (config.type === 'master') { + const participating = extension.isMasterParticipating(); + const message = participating ? "Master participating in workflows" : "Master disabled: running as orchestrator only"; + const badge = document.createElement("div"); + badge.style.cssText = this.styles.infoBox; + badge.textContent = message; + if (!participating) { + badge.style.backgroundColor = "#3a3a3a"; + badge.style.color = "#ffcc66"; + } + controlsWrapper.appendChild(badge); + } else if (config.dynamic && data) { if (isRemote) { const isCloud = data.type === 'cloud'; const workerTypeText = isCloud ? "Cloud worker" : "Remote worker"; diff --git a/web/workerUtils.js b/web/workerUtils.js index 194f523..d2f8831 100644 --- a/web/workerUtils.js +++ b/web/workerUtils.js @@ -155,6 +155,47 @@ export function findCollectorUpstreamNodes(apiPrompt, collectorIds) { return connected; } +/** + * Find all downstream nodes from the provided collector IDs. + * Used to keep only post-collection nodes when master runs in orchestrator mode. + * @param {Object} apiPrompt + * @param {Array} collectorIds + * @returns {Set} + */ +export function findCollectorDownstreamNodes(apiPrompt, collectorIds) { + const adjacency = new Map(); + + for (const [nodeId, node] of Object.entries(apiPrompt)) { + if (!node.inputs) continue; + for (const inputValue of Object.values(node.inputs)) { + if (Array.isArray(inputValue) && inputValue.length === 2) { + const sourceId = String(inputValue[0]); + if (!adjacency.has(sourceId)) { + adjacency.set(sourceId, new Set()); + } + adjacency.get(sourceId).add(String(nodeId)); + } + } + } + + const connected = new Set(collectorIds); + const queue = [...collectorIds]; + + while (queue.length > 0) { + const current = queue.shift(); + const dependents = adjacency.get(current); + if (!dependents) continue; + dependents.forEach((dependentId) => { + if (!connected.has(dependentId)) { + connected.add(dependentId); + queue.push(dependentId); + } + }); + } + + return connected; +} + /** * Prune workflow to only include nodes connected to distributed nodes * @param {Object} apiPrompt - The full workflow API prompt From 89b7812fb41b57d1a0bd69d9a750c58964069480 Mon Sep 17 00:00:00 2001 From: IT-BillDeng Date: Mon, 27 Oct 2025 03:00:57 +0800 Subject: [PATCH 2/3] Improve master participation logic with fallback handling and UI messaging --- web/executionUtils.js | 4 +++- web/main.js | 14 ++++++++++- web/ui.js | 54 +++++++++++++++++++++++++++++++++---------- 3 files changed, 58 insertions(+), 14 deletions(-) diff --git a/web/executionUtils.js b/web/executionUtils.js index 453e2f6..c53a016 100644 --- a/web/executionUtils.js +++ b/web/executionUtils.js @@ -135,7 +135,9 @@ export async function executeParallelDistributed(extension, promptWrapper) { const wantsDelegate = Boolean(extension.config?.settings?.master_delegate_only); let masterDelegateActive = false; if (wantsDelegate) { - if (upscaleNodes.length > 0) { + if (activeWorkers.length === 0) { + extension.log("Master delegate-only mode disabled: no active workers detected. Falling back to master execution.", "debug"); + } else if (upscaleNodes.length > 0) { extension.log("Master delegate-only mode is not yet supported for UltimateSDUpscaleDistributed nodes. Falling back to normal execution.", "warn"); } else if (collectorNodes.length === 0) { extension.log("Master delegate-only mode requested but no DistributedCollector nodes found. Falling back to normal execution.", "debug"); diff --git a/web/main.js b/web/main.js index 0e3934c..f2c89ae 100644 --- a/web/main.js +++ b/web/main.js @@ -90,8 +90,16 @@ class DistributedExtension { return this.enabledWorkers.length > 0; } + isMasterParticipationEnabled() { + return !Boolean(this.config?.settings?.master_delegate_only); + } + + isMasterFallbackActive() { + return Boolean(this.config?.settings?.master_delegate_only) && this.enabledWorkers.length === 0; + } + isMasterParticipating() { - return !(this.config?.settings?.master_delegate_only); + return this.isMasterParticipationEnabled() || this.isMasterFallbackActive(); } async updateMasterParticipation(enabled) { @@ -163,6 +171,10 @@ class DistributedExtension { } catch (error) { this.log("Error updating worker: " + error.message, "error"); } + + if (this.panelElement) { + renderSidebarContent(this, this.panelElement); + } } async _updateSetting(key, value) { diff --git a/web/ui.js b/web/ui.js index 5444b7d..04d5a8d 100644 --- a/web/ui.js +++ b/web/ui.js @@ -17,7 +17,14 @@ const cardConfigs = { const cudaDevice = extension.config?.master?.cuda_device ?? extension.masterCudaDevice; const cudaInfo = cudaDevice !== undefined ? `CUDA ${cudaDevice} • ` : ''; const port = window.location.port || (window.location.protocol === 'https:' ? '443' : '80'); - const delegateBadge = extension.isMasterParticipating() ? '' : `
Orchestrator-only mode`; + const participationEnabled = extension.isMasterParticipationEnabled(); + const fallbackActive = extension.isMasterFallbackActive(); + let delegateBadge = ''; + if (!participationEnabled) { + delegateBadge = fallbackActive + ? `
Fallback active • Master executing` + : `
Orchestrator-only mode`; + } return `${data?.name || extension.config?.master?.name || "Master"}
${cudaInfo}Port ${port}${delegateBadge}`; }, controls: { @@ -867,20 +874,32 @@ export class DistributedUI { const isMasterToggle = config?.masterToggle && typeof extension.isMasterParticipating === 'function'; if (isMasterToggle) { - const participating = extension.isMasterParticipating(); - checkbox.checked = participating; + const participationEnabled = extension.isMasterParticipationEnabled(); + const fallbackActive = extension.isMasterFallbackActive(); + const buildTitle = (enabled, fallback) => { + if (enabled) { + return "Master participating • Click to switch to orchestrator-only"; + } + if (fallback) { + return "No workers selected • Master fallback execution active"; + } + return "Master orchestrator-only • Click to re-enable participation"; + }; + + checkbox.checked = participationEnabled; checkbox.style.pointerEvents = "none"; column.style.cursor = "pointer"; - column.title = participating ? "Master participating • Click to switch to orchestrator-only" : "Master orchestrator-only • Click to re-enable participation"; + column.title = buildTitle(participationEnabled, fallbackActive); column.onclick = async (event) => { if (event) { event.stopPropagation(); event.preventDefault(); } - const targetState = !checkbox.checked; - checkbox.checked = targetState; - column.title = targetState ? "Master participating • Click to switch to orchestrator-only" : "Master orchestrator-only • Click to re-enable participation"; - await extension.updateMasterParticipation(targetState); + const nextState = !extension.isMasterParticipationEnabled(); + const nextFallback = !nextState && extension.enabledWorkers.length === 0; + checkbox.checked = nextState; + column.title = buildTitle(nextState, nextFallback); + await extension.updateMasterParticipation(nextState); }; } else if (config?.enabled && !config?.disabled && data?.id) { checkbox.style.pointerEvents = "none"; @@ -955,14 +974,25 @@ export class DistributedUI { controlsWrapper.style.cssText = this.styles.controlsWrapper; if (config.type === 'master') { - const participating = extension.isMasterParticipating(); - const message = participating ? "Master participating in workflows" : "Master disabled: running as orchestrator only"; + const participationEnabled = extension.isMasterParticipationEnabled(); + const fallbackActive = extension.isMasterFallbackActive(); + let message; const badge = document.createElement("div"); badge.style.cssText = this.styles.infoBox; - badge.textContent = message; - if (!participating) { + if (fallbackActive) { + message = "No workers selected. Master fallback execution active."; + badge.textContent = message; + badge.style.backgroundColor = "#243024"; + badge.style.color = "#6bd06b"; + badge.style.border = "1px solid #335533"; + } else if (!participationEnabled) { + message = "Master disabled: running as orchestrator only."; + badge.textContent = message; badge.style.backgroundColor = "#3a3a3a"; badge.style.color = "#ffcc66"; + } else { + message = "Master participating in workflows."; + badge.textContent = message; } controlsWrapper.appendChild(badge); } else if (config.dynamic && data) { From ef8e0542c83c741c6556c9363c88093f70f305f2 Mon Sep 17 00:00:00 2001 From: IT-BillDeng Date: Mon, 27 Oct 2025 03:03:17 +0800 Subject: [PATCH 3/3] Document master participation, orchestrator-only, and fallback modes in the worker setup guide --- docs/worker-setup-guides.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/worker-setup-guides.md b/docs/worker-setup-guides.md index 20e35ef..1d1dba6 100644 --- a/docs/worker-setup-guides.md +++ b/docs/worker-setup-guides.md @@ -6,6 +6,14 @@ +### Master participation modes + +The master can either contribute GPU work or stay in **orchestrator-only** mode: + +- **Participating**: Master renders alongside workers, useful when you want every available GPU. +- **Orchestrator-only**: Master sends jobs to selected workers but skips local rendering. Enable this by opening the Distributed panel and unchecking the master toggle. The master card will display *“Master disabled: running as orchestrator only.”* +- **Fallback**: If orchestrator-only is enabled but no workers remain selected, the master automatically re-enables execution to guarantee the workflow still runs. The UI shows a green *“Master fallback execution active”* badge so you know work is executing locally again. + ### Types of Workers - **Local workers**: Additional GPUs on the same machine as the master