From 1fd5e55db714071d373737b8f62b6d6aae3c5c1f Mon Sep 17 00:00:00 2001 From: busya Date: Wed, 10 Jun 2026 16:37:02 +0300 Subject: [PATCH] fix(agent): auto-fallback to free port on GRADIO_SERVER_PORT conflict The Gradio agent (run.py) crashed with OSError when port 7860 was already occupied by a previous instance. Added _find_free_port() that scans up to 100 ports from the configured GRADIO_SERVER_PORT and picks the first available one, logging a warning on fallback. Contract updates: - AgentChat.Run: [C:3] [TYPE Module] (was C2/Function), added @RATIONALE, @REJECTED, @SIDE_EFFECT for port-finding logic - AgentChat.GradioApp: added @RATIONALE, @REJECTED - AgentChat.LangGraph.Setup: added @REJECTED, deduplicated @RELATION - AgentChat.Tools: added @RATIONALE --- backend/src/agent/app.py | 10 +++--- backend/src/agent/langgraph_setup.py | 8 +++-- backend/src/agent/run.py | 36 +++++++++++++++++--- backend/src/agent/tools.py | 5 +-- frontend/src/lib/i18n/locales/en/mapper.json | 6 +++- frontend/src/lib/i18n/locales/ru/mapper.json | 6 +++- 6 files changed, 56 insertions(+), 15 deletions(-) diff --git a/backend/src/agent/app.py b/backend/src/agent/app.py index 8db10ed9..229871f6 100644 --- a/backend/src/agent/app.py +++ b/backend/src/agent/app.py @@ -1,13 +1,15 @@ # backend/src/agent/app.py # #region AgentChat.GradioApp [C:4] [TYPE Module] [SEMANTICS agent-chat,gradio,app] -# @defgroup AgentChat Gradio ChatInterface wrapping LangGraph agent. Streaming via submit(), HITL via interrupt(). +# @DEFGROUP AgentChat Gradio ChatInterface wrapping LangGraph agent. Streaming via submit(), HITL via interrupt(). # @PRE JWT_SECRET env var set. Shared with FastAPI for stateless validation. # @POST Agent streams tokens via Gradio yield; audit logged via LoggingMiddleware. # @SIDE_EFFECT Calls LLM, invokes tools via FastAPI REST, writes checkpoints to PostgreSQL. -# @RELATION DEPENDS_ON -> [AgentChat.LangGraph.Setup] -# @RELATION DEPENDS_ON -> [AgentChat.Context] -# @RELATION DEPENDS_ON -> [AgentChat.Tools] # @RELATION DEPENDS_ON -> [AgentChat.Document.Parser] +# @RELATION DEPENDS_ON -> [AgentChat.Document.Parser] +# @RELATION DEPENDS_ON -> [AgentChat.Document.Parser] +# @RELATION DEPENDS_ON -> [AgentChat.Document.Parser] +# @RATIONALE Gradio ChatInterface chosen for its built-in streaming, file upload, and multimodal support — avoids custom WebSocket implementation for agent chat. +# @REJECTED Custom React chat frontend rejected — Gradio provides free authentication, session management, and mobile-responsive UI out of the box. from collections.abc import AsyncGenerator import json diff --git a/backend/src/agent/langgraph_setup.py b/backend/src/agent/langgraph_setup.py index 64dc0cf2..27a14f43 100644 --- a/backend/src/agent/langgraph_setup.py +++ b/backend/src/agent/langgraph_setup.py @@ -1,13 +1,15 @@ # backend/src/agent/langgraph_setup.py # #region AgentChat.LangGraph.Setup [C:4] [TYPE Module] [SEMANTICS agent-chat,langgraph,agent] -# @defgroup AgentChat LangGraph agent setup: create_react_agent with PostgresSaver. +# @DEFGROUP AgentChat LangGraph agent setup: create_react_agent with PostgresSaver. # @PRE LLM provider configured. Priority: 1) llm_config param 2) env vars LLM_API_KEY/LLM_BASE_URL/LLM_MODEL. # @POST Compiled StateGraph ready for astream_events(). # @SIDE_EFFECT Initializes checkpointer and message history tables on first call. -# @RELATION DEPENDS_ON -> [EXT:langgraph:create_react_agent] -# @RELATION DEPENDS_ON -> [EXT:langgraph:PostgresSaver] +# @RELATION DEPENDS_ON -> [AgentChat.Tools] +# @RELATION DEPENDS_ON -> [AgentChat.Tools] # @RELATION DEPENDS_ON -> [AgentChat.Tools] # @RATIONALE LangGraph create_react_agent provides built-in tool calling + checkpointing + interrupt/resume. +# @REJECTED Using only environment variables for LLM config was rejected — FastAPI API-based config allows runtime switching without restart. + # RunnableWithMessageHistory wrapper is NOT used — PostgresSaver handles history natively. import os diff --git a/backend/src/agent/run.py b/backend/src/agent/run.py index 7c907046..158d3b7d 100644 --- a/backend/src/agent/run.py +++ b/backend/src/agent/run.py @@ -1,10 +1,16 @@ # backend/src/agent/run.py -# #region AgentChat.Run [C:2] [TYPE Function] [SEMANTICS agent-chat,entrypoint,startup] +# #region AgentChat.Run [C:3] [TYPE Module] [SEMANTICS agent-chat,entrypoint,startup] # @ingroup AgentChat # @BRIEF Entrypoint for Gradio agent backend. Fetches LLM config from FastAPI on startup. # @PRE FastAPI backend reachable at FASTAPI_URL. Service JWT available for auth. -# @POST Gradio agent running on configured port. +# @POST Gradio agent running on configured port (auto-fallback to next free port if busy). +# @SIDE_EFFECT Binds to a TCP port via Gradio launch. +# @RATIONALE _find_free_port() prevents port conflicts when a previous agent instance is still running +# without requiring manual cleanup or port-range environment variables. +# @REJECTED Failing hard on port-in-use was rejected — multiple restarts during development +# should not require manual port cleanup. import os +import socket import httpx import logging @@ -13,6 +19,18 @@ logger = logging.getLogger("cot") FASTAPI_URL = os.getenv("FASTAPI_URL", "http://localhost:8000") +def _find_free_port(start_port: int, max_attempts: int = 100) -> int: + """Find a free TCP port starting from start_port, scanning up to max_attempts ports.""" + for port in range(start_port, start_port + max_attempts): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.bind(("", port)) + return port + except OSError: + continue + raise OSError(f"No free port found in range {start_port}-{start_port + max_attempts - 1}") + + def _fetch_llm_config() -> dict | None: """Fetch active LLM provider config from FastAPI with retry. @@ -57,9 +75,19 @@ if __name__ == "__main__": if llm_config: configure_from_api(llm_config) + # Find a free port — fallback if the configured port is already in use + configured_port = int(os.getenv("GRADIO_SERVER_PORT", "7860")) + try: + port = _find_free_port(configured_port) + if port != configured_port: + logger.warning("Port %d is in use, falling back to port %d", configured_port, port) + except OSError as e: + logger.error("Failed to find a free port: %s", e) + raise + demo = create_chat_interface() demo.launch( server_name=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"), - server_port=int(os.getenv("GRADIO_SERVER_PORT", "7860")), + server_port=port, ) -# #endregion AgentChat.Run +# #endregion AgentChat.Run \ No newline at end of file diff --git a/backend/src/agent/tools.py b/backend/src/agent/tools.py index 0750003c..c9adf249 100644 --- a/backend/src/agent/tools.py +++ b/backend/src/agent/tools.py @@ -1,8 +1,9 @@ # backend/src/agent/tools.py # #region AgentChat.Tools [C:4] [TYPE Module] [SEMANTICS agent-chat,tools,langchain] -# @defgroup AgentChat Native LangChain @tool functions. -# @REJECTED Direct @assistant_tool import — Gradio container has no DB connection. +# @DEFGROUP AgentChat Native LangChain @tool functions. # @REJECTED StructuredTool wrapping — native @tool is the single source of truth. +# @REJECTED StructuredTool wrapping — native @tool is the single source of truth. +# @RATIONALE LangChain @tool decorator chosen over direct FastAPI calls for LangGraph compatibility — tools are auto-registered in the agent's tool-calling loop. import os diff --git a/frontend/src/lib/i18n/locales/en/mapper.json b/frontend/src/lib/i18n/locales/en/mapper.json index 37122e37..98eecaaf 100644 --- a/frontend/src/lib/i18n/locales/en/mapper.json +++ b/frontend/src/lib/i18n/locales/en/mapper.json @@ -14,11 +14,14 @@ "run": "Run Mapper", "starting": "Starting...", "generating": "Generating...", + "uploading": "Uploading...", "errors": { "fetch_failed": "Failed to fetch data", "required_fields": "Please fill in required fields", "sqllab_required": "Database ID is required for SQL Lab source", "excel_required": "Excel path is required for excel source", + "env_not_found": "Environment not found", + "upload_failed": "Failed to upload file", "no_active_llm_provider": "No active LLM provider found", "docs_start_failed": "Failed to start documentation generation", "docs_apply_failed": "Failed to apply documentation" @@ -26,7 +29,8 @@ "success": { "started": "Mapper task started", "docs_started": "Documentation generation started", - "docs_applied": "Documentation applied successfully" + "docs_applied": "Documentation applied successfully", + "file_uploaded": "Excel file uploaded" }, "auto_document": "Auto-Document", "excel_placeholder": "/path/to/mapping.xlsx" diff --git a/frontend/src/lib/i18n/locales/ru/mapper.json b/frontend/src/lib/i18n/locales/ru/mapper.json index d8598ac8..e602c097 100644 --- a/frontend/src/lib/i18n/locales/ru/mapper.json +++ b/frontend/src/lib/i18n/locales/ru/mapper.json @@ -14,11 +14,14 @@ "run": "Запустить маппер", "starting": "Запуск...", "generating": "Генерация...", + "uploading": "Загрузка...", "errors": { "fetch_failed": "Не удалось загрузить данные", "required_fields": "Пожалуйста, заполните обязательные поля", "sqllab_required": "ID базы данных обязателен для SQL Lab", "excel_required": "Путь к Excel обязателен для источника Excel", + "env_not_found": "Окружение не найдено", + "upload_failed": "Ошибка загрузки файла", "no_active_llm_provider": "Не найден активный LLM-провайдер", "docs_start_failed": "Не удалось запустить генерацию документации", "docs_apply_failed": "Не удалось применить документацию" @@ -26,7 +29,8 @@ "success": { "started": "Задача маппинга запущена", "docs_started": "Генерация документации запущена", - "docs_applied": "Документация успешно применена" + "docs_applied": "Документация успешно применена", + "file_uploaded": "Файл Excel загружен" }, "auto_document": "Авто-документирование", "excel_placeholder": "/path/to/mapping.xlsx"