diff --git a/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py b/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py index 994406132..2dcec5afa 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py +++ b/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py @@ -334,6 +334,14 @@ def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic return {"template_id": tid, "status": "success", "node": _filter_node_response(resp, fields)} except Exception as e: return {"template_id": tid, "status": "error", "error": str(e)} + if any(not node.get("name") for node in nodes): + # The controller assigns default names (R-1, R-2, ...) and console + # ports in request arrival order, and a parallel fan-out makes the + # arrival order depend on thread scheduling. Batches that rely on + # default naming are therefore created sequentially so those + # server-side assignments follow the submission order; batches + # where every node has an explicit name stay parallel. + return [_create_one(node) for node in nodes] with ThreadPoolExecutor(max_workers=min(len(nodes), BATCH_MAX_WORKERS)) as pool: # pool.map keeps the submission order, so callers can correlate # results with the nodes they sent regardless of completion order diff --git a/gns3server/agent/mcp/__init__.py b/gns3server/agent/mcp/__init__.py index b04a764ff..6d65ff11c 100644 --- a/gns3server/agent/mcp/__init__.py +++ b/gns3server/agent/mcp/__init__.py @@ -510,6 +510,9 @@ async def node_create( Single mode: provide template_id, x, y (optional compute_id) Batch mode: provide nodes=[{name, template_id?, x?, y?, compute_id?}] — creates up to 100 in parallel. Top-level template_id applies to all nodes; individual nodes can override. + Results are always returned in submission order; correlate nodes by node_id, not name. + When a node omits `name`, the server assigns a default name (R-1, R-2, ...) and console + port — such batches are created sequentially so those assignments follow submission order. """ if nodes is not None: return await asyncio.to_thread(_run_handler_sync, create_node_handler, { diff --git a/tests/agent/mcp/test_handlers.py b/tests/agent/mcp/test_handlers.py index c95846870..c5cbbbfd5 100644 --- a/tests/agent/mcp/test_handlers.py +++ b/tests/agent/mcp/test_handlers.py @@ -201,6 +201,46 @@ class TestNode: }, ctx) assert [r["node"]["name"] for r in result] == ["slow", "mid", "fast"] + def test_create_batch_default_names_created_sequentially(self, ctx): + import threading + import time + from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler + + def _run(nodes_param): + with patch(f"{AH}._get_connector") as m: + conn = _mock_conn() + lock = threading.Lock() + active = [0, 0] # in-flight requests, high-water mark + counter = [0] + + def _http_call(method, url, json_data=None, **kwargs): + with lock: + active[0] += 1 + active[1] = max(active[1], active[0]) + counter[0] += 1 + seq = counter[0] + time.sleep(0.05) # wide enough that parallel calls would overlap + with lock: + active[0] -= 1 + resp = MagicMock() + resp.json.return_value = {"node_id": f"n{seq}", "name": json_data.get("name", f"R-{seq}")} + return resp + + conn.http_call.side_effect = _http_call + m.return_value = conn + result = create_node_handler({"project_id": "p1", "template_id": "t1", "nodes": nodes_param}, ctx) + return result, active[1] + + # nodes relying on default naming are created one at a time so the + # server assigns default names/console ports in submission order + result, max_active = _run([{}, {}, {}]) + assert [r["node"]["name"] for r in result] == ["R-1", "R-2", "R-3"] + assert max_active == 1 + # one nameless node is enough to serialize the whole batch + result, max_active = _run([{"name": "explicit"}, {}]) + assert [r["node"]["name"] for r in result] == ["explicit", "R-2"] + assert max_active == 1 + def test_create_missing_project_id(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler assert create_node_handler({}, ctx) == {"error": "project_id is required"}