diff --git a/.serena/project.yml b/.serena/project.yml index edba1eeb..e74ef79f 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -60,6 +60,5 @@ initial_prompt: "" project_name: "serena" languages: - python -- typescript included_optional_tools: [] encoding: utf-8 diff --git a/src/serena/agent.py b/src/serena/agent.py index e4e1199d..61a56de7 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -2,23 +2,17 @@ The Serena Model Context Protocol (MCP) Server """ -import concurrent.futures import multiprocessing import os import platform import sys -import threading import webbrowser from collections.abc import Callable -from concurrent.futures import Future from logging import Logger -from queue import Queue -from threading import Thread -from typing import TYPE_CHECKING, Any, Optional, TypeVar +from typing import TYPE_CHECKING, Optional, TypeVar from sensai.util import logging from sensai.util.logging import LogTime -from sensai.util.string import ToStringMixin from interprompt.jinja_template import JinjaTemplate from serena import serena_version @@ -29,6 +23,7 @@ from serena.dashboard import SerenaDashboardAPI from serena.ls_manager import LanguageServerManager from serena.project import Project from serena.prompt_factory import SerenaPromptFactory +from serena.task_executor import TaskExecutor from serena.tools import ActivateProjectTool, GetCurrentConfigTool, Tool, ToolMarker, ToolRegistry from serena.util.inspection import iter_subclasses from serena.util.logging import MemoryLogHandler @@ -156,12 +151,8 @@ class SerenaAgent: log.info(f"Number of exposed tools: {len(self._exposed_tools)}") # create executor for starting the language server and running tools in another thread - # This executor is used to achieve linear task execution, so it is important to use a single-threaded executor. - self._task_executor_queue: Queue = Queue() - self._task_executor_thread = Thread(target=self._process_task_queue, name="SerenaAgentTaskExecutor", daemon=True) - self._task_executor_thread.start() - self._task_executor_lock = threading.Lock() - self._task_executor_task_index = 1 + # This executor is used to achieve linear task execution + self._task_executor = TaskExecutor("SerenaAgentTaskExecutor") # Initialize the prompt factory self.prompt_factory = SerenaPromptFactory() @@ -198,31 +189,22 @@ class SerenaAgent: process.start() process.join(timeout=1) - class Task(ToStringMixin): - def __init__(self, function: Callable[[], Any], name: str): - self.name = name - self.future: concurrent.futures.Future = concurrent.futures.Future() - self._function = function + def get_current_tasks(self) -> list[TaskExecutor.TaskInfo]: + """ + Gets the list of tasks currently running or queued for execution. + The function returns a list of thread-safe TaskInfo objects (specifically created for the caller). - def _tostring_includes(self) -> list[str]: - return ["name"] + :return: the list of tasks in the execution order (running task first) + """ + return self._task_executor.get_current_tasks() - def execute(self) -> None: - """ - Execute the task, setting the result or exception on the future. - """ - try: - with LogTime(self.name, logger=log): - result = self._function() - self.future.set_result(result) - except Exception as e: - log.error(f"Error during execution of {self.name}: {e}", exc_info=e) - self.future.set_exception(e) + def get_last_executed_task(self) -> TaskExecutor.TaskInfo | None: + """ + Gets the last executed task. - def _process_task_queue(self) -> None: - while True: - task: SerenaAgent.Task = self._task_executor_queue.get() - task.execute() + :return: the last executed task info or None if no task has been executed yet + """ + return self._task_executor.get_last_executed_task() def get_language_server_manager(self) -> LanguageServerManager | None: if self._active_project is not None: @@ -396,33 +378,33 @@ class SerenaAgent: log.info(f"Active tools ({len(self._active_tools)}): {', '.join(self.get_active_tool_names())}") - def issue_task(self, task: Callable[[], Any], name: str | None = None) -> Future: + def issue_task( + self, task: Callable[[], T], name: str | None = None, logged: bool = True, timeout: float | None = None + ) -> TaskExecutor.Task[T]: """ Issue a task to the executor for asynchronous execution. It is ensured that tasks are executed in the order they are issued, one after another. :param task: the task to execute :param name: the name of the task for logging purposes; if None, use the task function's name - :return: a Future object representing the execution of the task + :param logged: whether to log management of the task; if False, only errors will be logged + :param timeout: the maximum time to wait for task completion in seconds, or None to wait indefinitely + :return: the task object, through which the task's future result can be accessed """ - with self._task_executor_lock: - task_name = f"Task-{self._task_executor_task_index}[{name or task.__name__}]" - self._task_executor_task_index += 1 - log.info(f"Scheduling {task_name}") - task = SerenaAgent.Task(function=task, name=task_name) - self._task_executor_queue.put(task) - return task.future + return self._task_executor.issue_task(task, name=name, logged=logged, timeout=timeout) - def execute_task(self, task: Callable[[], T], name: str | None = None) -> T: + def execute_task(self, task: Callable[[], T], name: str | None = None, logged: bool = True, timeout: float | None = None) -> T: """ Executes the given task synchronously via the agent's task executor. This is useful for tasks that need to be executed immediately and whose results are needed right away. :param task: the task to execute + :param name: the name of the task for logging purposes; if None, use the task function's name + :param logged: whether to log management of the task; if False, only errors will be logged + :param timeout: the maximum time to wait for task completion in seconds, or None to wait indefinitely :return: the result of the task execution """ - future = self.issue_task(task, name=name) - return future.result() + return self._task_executor.execute_task(task, name=name, logged=logged, timeout=timeout) def is_using_language_server(self) -> bool: """ diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index ac6ec325..3be3f3d5 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -2,7 +2,7 @@ import os import socket import threading from collections.abc import Callable -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Self from flask import Flask, Response, request, send_from_directory from pydantic import BaseModel @@ -10,6 +10,7 @@ from sensai.util import logging from serena.analytics import ToolUsageStats from serena.constants import SERENA_DASHBOARD_DIR +from serena.task_executor import TaskExecutor from serena.util.logging import MemoryLogHandler if TYPE_CHECKING: @@ -85,6 +86,28 @@ class RequestDeleteMemory(BaseModel): memory_name: str +class RequestCancelTaskExecution(BaseModel): + task_id: int + + +class QueuedExecution(BaseModel): + task_id: int + is_running: bool + name: str + finished_successfully: bool + logged: bool + + @classmethod + def from_task_info(cls, task_info: TaskExecutor.TaskInfo) -> Self: + return cls( + task_id=task_info.task_id, + is_running=task_info.is_running, + name=task_info.name, + finished_successfully=task_info.finished_successfully(), + logged=task_info.logged, + ) + + class SerenaDashboardAPI: log = logging.getLogger(__qualname__) @@ -152,7 +175,7 @@ class SerenaDashboardAPI: @self._app.route("/get_config_overview", methods=["GET"]) def get_config_overview() -> dict[str, Any]: - result = self._get_config_overview() + result = self._agent.execute_task(self._get_config_overview, logged=False) return result.model_dump() @self._app.route("/shutdown", methods=["PUT"]) @@ -225,6 +248,41 @@ class SerenaDashboardAPI: except Exception as e: return {"status": "error", "message": str(e)} + @self._app.route("/queued_task_executions", methods=["GET"]) + def get_queued_executions() -> dict[str, Any]: + try: + current_executions = self._agent.get_current_tasks() + response = [QueuedExecution.from_task_info(task_info).model_dump() for task_info in current_executions] + return {"queued_executions": response, "status": "success"} + except Exception as e: + return {"status": "error", "message": str(e)} + + @self._app.route("/cancel_task_execution", methods=["POST"]) + def cancel_task_execution() -> dict[str, Any]: + request_data = request.get_json() + try: + request_cancel_task = RequestCancelTaskExecution.model_validate(request_data) + for task in self._agent.get_current_tasks(): + if task.task_id == request_cancel_task.task_id: + task.cancel() + return {"status": "success", "was_cancelled": True} + return { + "status": "success", + "was_cancelled": False, + "message": f"Task with id {request_data.get('task_id')} not found, maybe execution was already finished", + } + except Exception as e: + return {"status": "error", "message": str(e), "was_cancelled": False} + + @self._app.route("/last_execution", methods=["GET"]) + def get_last_execution() -> dict[str, Any]: + try: + last_execution_info = self._agent.get_last_executed_task() + response = QueuedExecution.from_task_info(last_execution_info).model_dump() if last_execution_info is not None else None + return {"last_execution": response, "status": "success"} + except Exception as e: + return {"status": "error", "message": str(e)} + def _get_log_messages(self, request_log: RequestLog) -> ResponseLog: all_messages = self._memory_log_handler.get_log_messages() requested_messages = all_messages[request_log.start_idx :] if request_log.start_idx <= len(all_messages) else [] @@ -369,63 +427,70 @@ class SerenaDashboardAPI: def _get_available_languages(self) -> ResponseAvailableLanguages: from solidlsp.ls_config import Language - # Get all non-experimental languages - all_languages = [lang.value for lang in Language.iter_all(include_experimental=False)] + def run() -> ResponseAvailableLanguages: + all_languages = [lang.value for lang in Language.iter_all(include_experimental=False)] - # Filter out already added languages for the active project - project = self._agent.get_active_project() - if project: - current_languages = [lang.value for lang in project.project_config.languages] - available_languages = [lang for lang in all_languages if lang not in current_languages] - else: - available_languages = all_languages + # Filter out already added languages for the active project + project = self._agent.get_active_project() + if project: + current_languages = [lang.value for lang in project.project_config.languages] + available_languages = [lang for lang in all_languages if lang not in current_languages] + else: + available_languages = all_languages - return ResponseAvailableLanguages(languages=sorted(available_languages)) + return ResponseAvailableLanguages(languages=sorted(available_languages)) + + return self._agent.execute_task(run, logged=False) def _get_memory(self, request_get_memory: RequestGetMemory) -> ResponseGetMemory: - project = self._agent.get_active_project() - if project is None: - raise ValueError("No active project") + def run() -> ResponseGetMemory: + project = self._agent.get_active_project() + if project is None: + raise ValueError("No active project") - content = project.memories_manager.load_memory(request_get_memory.memory_name) - return ResponseGetMemory(content=content, memory_name=request_get_memory.memory_name) + content = project.memories_manager.load_memory(request_get_memory.memory_name) + return ResponseGetMemory(content=content, memory_name=request_get_memory.memory_name) + + return self._agent.execute_task(run, logged=False) def _save_memory(self, request_save_memory: RequestSaveMemory) -> None: - project = self._agent.get_active_project() - if project is None: - raise ValueError("No active project") + def run() -> None: + project = self._agent.get_active_project() + if project is None: + raise ValueError("No active project") - project.memories_manager.save_memory(request_save_memory.memory_name, request_save_memory.content) + project.memories_manager.save_memory(request_save_memory.memory_name, request_save_memory.content) + + self._agent.execute_task(run, logged=True, name="SaveMemory") def _delete_memory(self, request_delete_memory: RequestDeleteMemory) -> None: - project = self._agent.get_active_project() - if project is None: - raise ValueError("No active project") + def run() -> None: + project = self._agent.get_active_project() + if project is None: + raise ValueError("No active project") - project.memories_manager.delete_memory(request_delete_memory.memory_name) + project.memories_manager.delete_memory(request_delete_memory.memory_name) + + self._agent.execute_task(run, logged=True, name="DeleteMemory") def _add_language(self, request_add_language: RequestAddLanguage) -> None: from solidlsp.ls_config import Language - # Convert string to Language enum try: language = Language(request_add_language.language) except ValueError: raise ValueError(f"Invalid language: {request_add_language.language}") - - # Add the language to the active project + # add_language is already thread-safe self._agent.add_language(language) def _remove_language(self, request_remove_language: RequestRemoveLanguage) -> None: from solidlsp.ls_config import Language - # Convert string to Language enum try: language = Language(request_remove_language.language) except ValueError: raise ValueError(f"Invalid language: {request_remove_language.language}") - - # Remove the language from the active project + # remove_language is already thread-safe self._agent.remove_language(language) @staticmethod diff --git a/src/serena/resources/dashboard/dashboard.css b/src/serena/resources/dashboard/dashboard.css index da2e17af..92f1184d 100644 --- a/src/serena/resources/dashboard/dashboard.css +++ b/src/serena/resources/dashboard/dashboard.css @@ -338,6 +338,190 @@ body { color: var(--text-secondary); } +/* Executions Styles */ +.executions-section { + background-color: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 5px; + padding: 20px; + margin-bottom: 20px; + transition: background-color 0.3s ease, border-color 0.3s ease; +} + +.executions-section h2 { + margin-top: 0; + color: var(--text-secondary); +} + +.execution-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.execution-item { + display: flex; + align-items: center; + gap: 10px; + background-color: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 20px; + padding: 8px 12px; + min-height: 40px; + transition: background-color 0.2s ease, border-color 0.2s ease; +} + +.execution-item.running { + border-color: var(--btn-primary); + background: linear-gradient(to right, rgba(234, 164, 93, 0.1), var(--bg-primary)); +} + +.execution-item.cancelled { + border-color: var(--text-muted); + background-color: var(--bg-primary); + opacity: 0.7; +} + +.execution-item.abandoned { + border-color: var(--log-error); + background: linear-gradient(to right, rgba(255, 0, 0, 0.1), var(--bg-primary)); +} + +.execution-spinner { + width: 16px; + height: 16px; + border: 2px solid var(--border-color); + border-top-color: var(--btn-primary); + border-radius: 50%; + animation: spin 0.7s linear infinite; + flex-shrink: 0; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.execution-name { + flex: 1; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text-primary); +} + +.execution-meta { + font-size: 11px; + color: var(--text-muted); + flex-shrink: 0; +} + +.execution-cancel-btn { + background: none; + border: none; + color: var(--text-muted); + font-size: 16px; + cursor: pointer; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: background-color 0.15s ease, color 0.15s ease; + flex-shrink: 0; +} + +.execution-cancel-btn:hover { + background-color: rgba(255, 0, 0, 0.1); + color: var(--log-error); +} + +.execution-icon { + width: 16px; + height: 16px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + flex-shrink: 0; +} + +.execution-icon.success { + background-color: rgba(34, 197, 94, 0.2); + border: 1px solid rgba(34, 197, 94, 0.6); + color: #22c55e; +} + +.execution-icon.cancelled { + background-color: var(--border-color); + border: 1px solid var(--text-muted); + color: var(--text-muted); +} + +.execution-icon.abandoned { + background-color: rgba(255, 0, 0, 0.2); + border: 1px solid var(--log-error); + color: var(--log-error); +} + +.execution-icon.error { + background-color: rgba(255, 0, 0, 0.2); + border: 1px solid var(--log-error); + color: var(--log-error); +} + +.last-execution-container { + display: flex; + align-items: center; + gap: 12px; + background: linear-gradient(to right, rgba(34, 197, 94, 0.08), transparent); + border: 1px solid rgba(34, 197, 94, 0.2); + border-radius: 8px; + padding: 12px; +} + +.last-execution-container.error { + background: linear-gradient(to right, rgba(255, 0, 0, 0.08), transparent); + border-color: rgba(255, 0, 0, 0.2); +} + +.last-execution-icon-container { + width: 28px; + height: 28px; + background-color: rgba(34, 197, 94, 0.2); + border: 1px solid rgba(34, 197, 94, 0.6); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + color: #22c55e; + flex-shrink: 0; +} + +.last-execution-container.error .last-execution-icon-container { + background-color: rgba(255, 0, 0, 0.2); + border-color: var(--log-error); + color: var(--log-error); +} + +.last-execution-body { + flex: 1; +} + +.last-execution-status { + font-size: 11px; + color: var(--text-muted); + margin-bottom: 2px; +} + +.last-execution-name { + font-size: 13px; + color: var(--text-primary); +} + .stat-bar-container { display: flex; align-items: center; @@ -730,7 +914,7 @@ body { } .language-add-btn:hover { - background: var(--bg-tertiary); + background: var(--border-color); border-color: var(--btn-primary); color: var(--btn-primary); } @@ -832,3 +1016,42 @@ body { flex-wrap: wrap; gap: 8px; } + +/* Copy Logs Button */ +.copy-logs-btn { + position: absolute; + top: 15px; + right: 15px; + z-index: 10; + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + background-color: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-primary); + cursor: pointer; + opacity: 0.8; + transition: opacity 0.2s ease, background-color 0.2s ease; + font-size: 13px; + font-weight: 500; +} + +.copy-logs-btn:hover { + opacity: 1; + background-color: var(--border-color); +} + +.copy-logs-btn svg { + flex-shrink: 0; +} + +.copy-logs-text { + display: none; + white-space: nowrap; +} + +.copy-logs-btn:hover .copy-logs-text { + display: inline; +} diff --git a/src/serena/resources/dashboard/dashboard.js b/src/serena/resources/dashboard/dashboard.js index 7722b018..efc525e0 100644 --- a/src/serena/resources/dashboard/dashboard.js +++ b/src/serena/resources/dashboard/dashboard.js @@ -60,18 +60,24 @@ class Dashboard { this.memoryContentDirty = false; this.memoryToDelete = null; this.isAddingLanguage = false; + this.waitingForPollingResult = false; + + // Execution tracking + this.cancelledExecutions = []; + this.executionToCancel = null; // Tool names and stats this.toolNames = []; this.currentMaxIdx = -1; this.pollInterval = null; this.configPollInterval = null; + this.executionsPollInterval = null; this.failureCount = 0; // jQuery elements this.$logContainer = $('#log-container'); this.$errorContainer = $('#error-container'); - this.$loadButton = $('#load-logs'); + this.$copyLogsBtn = $('#copy-logs-btn'); this.$menuToggle = $('#menu-toggle'); this.$menuDropdown = $('#menu-dropdown'); this.$menuShutdown = $('#menu-shutdown'); @@ -110,6 +116,19 @@ class Dashboard { this.$deleteMemoryOkBtn = $('#delete-memory-ok-btn'); this.$deleteMemoryCancelBtn = $('#delete-memory-cancel-btn'); this.$modalCloseDeleteMemory = $('.modal-close-delete-memory'); + this.$createMemoryModal = $('#create-memory-modal'); + this.$createMemoryProjectName = $('#create-memory-project-name'); + this.$createMemoryNameInput = $('#create-memory-name-input'); + this.$createMemoryCreateBtn = $('#create-memory-create-btn'); + this.$createMemoryCancelBtn = $('#create-memory-cancel-btn'); + this.$modalCloseCreateMemory = $('.modal-close-create-memory'); + this.$activeExecutionQueueDisplay = $('#active-executions-display'); + this.$lastExecutionDisplay = $('#last-execution-display'); + this.$cancelledExecutionsDisplay = $('#cancelled-executions-display'); + this.$cancelExecutionModal = $('#cancel-execution-modal'); + this.$cancelExecutionOkBtn = $('#cancel-execution-ok-btn'); + this.$cancelExecutionCancelBtn = $('#cancel-execution-cancel-btn'); + this.$modalCloseCancelExecution = $('.modal-close-cancel-execution'); // Chart references this.countChart = null; @@ -118,7 +137,7 @@ class Dashboard { this.outputChart = null; // Register event handlers - this.$loadButton.click(this.loadLogs.bind(this)); + this.$copyLogsBtn.click(this.copyLogs.bind(this)); this.$menuShutdown.click(function(e) { e.preventDefault(); self.shutdown(); @@ -140,6 +159,18 @@ class Dashboard { this.$deleteMemoryOkBtn.click(this.confirmDeleteMemoryOk.bind(this)); this.$deleteMemoryCancelBtn.click(this.closeDeleteMemoryModal.bind(this)); this.$modalCloseDeleteMemory.click(this.closeDeleteMemoryModal.bind(this)); + this.$createMemoryCreateBtn.click(this.createMemoryFromModal.bind(this)); + this.$createMemoryCancelBtn.click(this.closeCreateMemoryModal.bind(this)); + this.$modalCloseCreateMemory.click(this.closeCreateMemoryModal.bind(this)); + this.$createMemoryNameInput.keypress(function(e) { + if (e.which === 13) { // Enter key + e.preventDefault(); + self.createMemoryFromModal(); + } + }); + this.$cancelExecutionOkBtn.click(this.confirmCancelExecutionOk.bind(this)); + this.$cancelExecutionCancelBtn.click(this.closeCancelExecutionModal.bind(this)); + this.$modalCloseCancelExecution.click(this.closeCancelExecutionModal.bind(this)); // Page navigation $('[data-page]').click(function(e) { @@ -180,6 +211,12 @@ class Dashboard { } }); + this.$createMemoryModal.click(function(e) { + if ($(e.target).hasClass('modal')) { + self.closeCreateMemoryModal(); + } + }); + // Collapsible sections $('.collapsible-header').click(function() { const $header = $(this); @@ -204,6 +241,8 @@ class Dashboard { self.closeEditMemoryModal(); } else if (self.$deleteMemoryModal.is(':visible')) { self.closeDeleteMemoryModal(); + } else if (self.$createMemoryModal.is(':visible')) { + self.closeCreateMemoryModal(); } } }); @@ -213,6 +252,7 @@ class Dashboard { // Start on overview page self.loadConfigOverview(); self.startConfigPolling(); + self.startExecutionsPolling(); }); } @@ -244,6 +284,7 @@ class Dashboard { if (page === 'overview') { this.loadConfigOverview(); this.startConfigPolling(); + this.startExecutionsPolling(); } else if (page === 'logs') { this.loadLogs(); } else if (page === 'stats') { @@ -260,17 +301,25 @@ class Dashboard { clearInterval(this.configPollInterval); this.configPollInterval = null; } + if (this.executionsPollInterval) { + clearInterval(this.executionsPollInterval); + this.executionsPollInterval = null; + } } // ===== Config Overview Methods ===== loadConfigOverview() { + if (this.waitingForPollingResult) { + console.log('Still waiting for previous config poll result, skipping this poll'); + return; + } + this.waitingForPollingResult = true; let self = this; $.ajax({ url: '/get_config_overview', type: 'GET', success: function(response) { - console.log('Config overview response:', response); self.failureCount = 0; self.configData = response; self.jetbrainsMode = response.jetbrains_mode; @@ -295,6 +344,9 @@ class Dashboard { self.$availableToolsDisplay.html('
Error loading tools
'); self.$availableModesDisplay.html('
Error loading modes
'); self.$availableContextsDisplay.html('
Error loading contexts
'); + }, + complete: function() { + self.waitingForPollingResult = false; } }); } @@ -304,6 +356,17 @@ class Dashboard { this.configPollInterval = setInterval(this.loadConfigOverview.bind(this), 2000); } + startExecutionsPolling() { + // Poll every 1 second for executions (independent of config polling) + // This ensures stuck executions can still be cancelled even if config polling is blocked + this.loadExecutions(); + this.loadLastExecution(); + this.executionsPollInterval = setInterval(() => { + this.loadExecutions(); + this.loadLastExecution(); + }, 1000); + } + displayConfig(config) { try { // Check if tools and memories sections are currently expanded @@ -394,20 +457,25 @@ class Dashboard { html += ''; html += ''; - // Available memories - collapsible (only show if memories exist) - if (config.available_memories && config.available_memories.length > 0) { + // Available memories - collapsible (show if memories exist or if project exists) + if (config.active_project && config.active_project.name) { html += '
'; html += '

'; - html += 'Available Memories (' + config.available_memories.length + ')'; + const memoryCount = (config.available_memories && config.available_memories.length) || 0; + html += 'Available Memories (' + memoryCount + ')'; html += '▼'; html += '

'; html += '
'; - config.available_memories.forEach(function(memory) { - html += '
'; - html += memory; - html += '×'; - html += '
'; - }); + if (config.available_memories && config.available_memories.length > 0) { + config.available_memories.forEach(function(memory) { + html += '
'; + html += memory; + html += '×'; + html += '
'; + }); + } + // Add Create Memory button + html += ''; html += '
'; html += '
'; } @@ -447,6 +515,9 @@ class Dashboard { self.confirmDeleteMemory(memoryName); }); + // Attach event handler for create memory button + $('#create-memory-btn').click(this.openCreateMemoryModal.bind(this)); + // Re-attach collapsible handler for the newly created tools header $('#tools-header').click(function() { const $header = $(this); @@ -564,6 +635,279 @@ class Dashboard { this.$availableContextsDisplay.html(html); } + // ===== Executions Methods ===== + + loadExecutions() { + let self = this; + $.ajax({ + url: '/queued_task_executions', + type: 'GET', + success: function(response) { + if (response.status === 'success') { + self.displayActiveExecutionsQueue(response.queued_executions || []); + } else { + console.error('Error loading executions:', response.message); + } + }, + error: function(xhr, status, error) { + console.error('Error loading executions:', error); + self.$activeExecutionQueueDisplay.html('
Error loading executions
'); + } + }); + } + + loadLastExecution() { + let self = this; + $.ajax({ + url: '/last_execution', + type: 'GET', + success: function(response) { + if (response.status === 'success') { + if (response.last_execution !== null && response.last_execution.logged) { + self.displayLastExecution(response.last_execution); + } + } else { + console.error('Error loading last execution:', response.message); + } + }, + error: function(xhr, status, error) { + console.error('Error loading last execution:', error); + self.$lastExecutionDisplay.html('
Error loading last execution
'); + } + }); + } + + displayActiveExecutionsQueue(executions) { + if (!executions || executions.length === 0) { + return; + } + + let html = '
'; + let self = this; + + executions.forEach(function(execution) { + const isRunning = execution.is_running; + const logged = execution.logged; + + if (!logged) { + return; // Skip unlogged executions + } + + let itemClass = 'execution-item'; + if (isRunning) { + itemClass += ' running'; + } + + // Escape JSON for HTML attribute - replace single quotes and use HTML entities + const executionJson = JSON.stringify(execution).replace(/'/g, '''); + + html += '
'; + + if (isRunning) { + html += '
'; + } + + html += '
' + self.escapeHtml(execution.name) + '
'; + + if (isRunning) { + html += '
#' + execution.task_id + '
'; + } else { + html += '
queued · #' + execution.task_id + '
'; + } + + html += ''; + html += '
'; + }); + + html += '
'; + this.$activeExecutionQueueDisplay.html(html); + + // Attach event handlers for cancel buttons + $('.execution-cancel-btn').click(function(e) { + e.preventDefault(); + console.log('Cancel button clicked'); + const $item = $(this).closest('.execution-item'); + console.log('Found item:', $item.length); + const executionDataStr = $item.attr('data-execution'); + console.log('Execution data string:', executionDataStr); + if (executionDataStr) { + // Unescape HTML entities + const unescapedStr = executionDataStr.replace(/'/g, "'"); + const executionData = JSON.parse(unescapedStr); + console.log('Parsed execution data:', executionData); + self.confirmCancelExecution(executionData); + } else { + console.error('No execution data found on element'); + } + }); + + // Update cancelled executions display + this.displayCancelledExecutions(executions); + } + + displayLastExecution(execution) { + if (!execution) { + this.$lastExecutionDisplay.html('
No executions yet.
'); + return; + } + + const isSuccess = execution.finished_successfully; + let html = '
'; + + html += '
'; + html += isSuccess ? '✓' : '✕'; + html += '
'; + + html += '
'; + html += '
' + (isSuccess ? 'Succeeded' : 'Failed') + '
'; + html += '
' + this.escapeHtml(execution.name) + '
'; + html += '
'; + + html += '
#' + execution.task_id + '
'; + html += '
'; + + this.$lastExecutionDisplay.html(html); + } + + displayCancelledExecutions() { + let self = this; + const cancelledExecs = self.cancelledExecutions + + if (cancelledExecs.length === 0) { + // Hide the cancelled executions section + $('.executions-section').eq(2).hide(); + return; + } + + // Show the cancelled executions section + $('.executions-section').eq(2).show(); + + let html = '
'; + + cancelledExecs.forEach(function(execution) { + const isAbandoned = execution.is_running; + + html += '
'; + html += '
'; + html += isAbandoned ? '!' : '✕'; + html += '
'; + html += '
' + self.escapeHtml(execution.name) + '
'; + html += '
' + (isAbandoned ? 'abandoned · ' : '') + '#' + execution.task_id + '
'; + html += '
'; + }); + + html += '
'; + this.$cancelledExecutionsDisplay.html(html); + } + + confirmCancelExecution(executionData) { + console.log('confirmCancelExecution called with:', executionData); + this.executionToCancel = executionData; + + if (executionData.is_running) { + // Show modal for running executions + console.log('Showing modal for running execution'); + this.$cancelExecutionModal.fadeIn(200); + } else { + // Directly cancel queued executions + console.log('Directly cancelling queued execution'); + this.cancelExecution(executionData); + } + } + + confirmCancelExecutionOk() { + if (this.executionToCancel) { + this.cancelExecution(this.executionToCancel); + } + this.closeCancelExecutionModal(); + } + + cancelExecution(executionData) { + const self = this; + + console.log('cancelExecution called with full execution data:', executionData); + console.log('Attempting to cancel task:', executionData.task_id); + + // Call backend API to cancel the task + $.ajax({ + url: '/cancel_task_execution', + type: 'POST', + contentType: 'application/json', + data: JSON.stringify({ + task_id: executionData.task_id + }), + success: function(response) { + console.log('Cancel task response:', response); + + if (response.status === 'error') { + console.error('Backend returned error status:', response.message); + alert('Error cancelling task: ' + response.message); + return; + } + + if (response.status === 'success') { + if (response.was_cancelled) { + console.log('Task ' + executionData.task_id + ' was successfully cancelled'); + // Add to cancelled list (only managed in JS, not persisted) + const alreadyCancelled = self.cancelledExecutions.some(function(exec) { + return exec.task_id === executionData.task_id; + }); + if (!alreadyCancelled) { + console.log('Adding execution to cancelled list:', executionData); + self.cancelledExecutions.push(executionData); + console.log('Cancelled executions array now contains:', self.cancelledExecutions); + } else { + console.log('Execution already in cancelled list'); + } + } else { + console.log('Task ' + executionData.task_id + ' could not be cancelled (may have already completed). ' + response.message ); + } + // Refresh display regardless + self.loadExecutions(); + } else { + console.error('Unexpected response status:', response.status); + alert('Unexpected response from server'); + } + }, + error: function(xhr, status, error) { + console.error('AJAX error cancelling task:'); + console.error(' Status:', status); + console.error(' Error:', error); + console.error(' XHR:', xhr); + console.error(' Response:', xhr.responseText); + + let errorMessage = error; + if (xhr.responseJSON && xhr.responseJSON.message) { + errorMessage = xhr.responseJSON.message; + } else if (xhr.responseText) { + errorMessage = xhr.responseText; + } + + alert('Error cancelling task: ' + errorMessage); + } + }); + } + + closeCancelExecutionModal() { + this.$cancelExecutionModal.fadeOut(200); + this.executionToCancel = null; + } + + escapeHtml(text) { + if (typeof text !== 'string') return text; + + const patterns = { + '<': '<', + '>': '>', + '&': '&', + '"': '"', + "'": ''', + '`': '`' + }; + + return text.replace(/[<>&"'`]/g, match => patterns[match]); + } + // ===== Logs Methods ===== displayLogMessage(message) { @@ -589,12 +933,34 @@ class Dashboard { document.title = activeProject ? `${activeProject} – Serena Dashboard` : 'Serena Dashboard'; } + copyLogs() { + const logText = this.$logContainer.text(); + + if (!logText) { + alert('No logs to copy'); + return; + } + + // Use the Clipboard API to copy text + navigator.clipboard.writeText(logText).then(() => { + // Visual feedback - temporarily change icon to grey checkmark + const originalHtml = this.$copyLogsBtn.html(); + const checkmarkSvg = 'copy logs'; + this.$copyLogsBtn.html(checkmarkSvg); + + setTimeout(() => { + this.$copyLogsBtn.html(originalHtml); + }, 1500); + }).catch(err => { + console.error('Failed to copy logs:', err); + alert('Failed to copy logs to clipboard'); + }); + } + loadLogs() { console.log("Loading logs"); let self = this; - // Disable button and show loading state - self.$loadButton.prop('disabled', true).text('Loading...'); self.$errorContainer.empty(); // Make API call @@ -634,10 +1000,6 @@ class Dashboard { console.error('Error loading logs:', error); self.$errorContainer.html('
Error loading logs: ' + (xhr.responseJSON ? xhr.responseJSON.detail : error) + '
'); - }, - complete: function() { - // Re-enable button - self.$loadButton.prop('disabled', false).text('Reload Log'); } }); } @@ -1149,7 +1511,7 @@ class Dashboard { // Reload config to show updated language list self.loadConfigOverview(); } else { - alert('Error: ' + response.message); + alert('Error removing language ' + language + ": " + response.message); } }, error: function(xhr, status, error) { @@ -1232,7 +1594,7 @@ class Dashboard { if (response.status === 'success') { console.log("Language added successfully"); } else { - alert('Error: ' + response.message); + alert('Error adding language ' + selectedLanguage + ": " + response.message); // Restore button visibility on error $('#add-language-btn').show(); $('#add-language-spinner').hide(); @@ -1398,6 +1760,78 @@ class Dashboard { }); } + openCreateMemoryModal() { + // Set project name in modal + this.$createMemoryProjectName.text(this.activeProjectName || 'Unknown'); + + // Clear the input field + this.$createMemoryNameInput.val(''); + + // Show modal + this.$createMemoryModal.fadeIn(200); + + // Focus on the input field + setTimeout(() => { + this.$createMemoryNameInput.focus(); + }, 250); + } + + closeCreateMemoryModal() { + this.$createMemoryModal.fadeOut(200); + this.$createMemoryNameInput.val(''); + this.$createMemoryCreateBtn.prop('disabled', false).text('Create'); + } + + createMemoryFromModal() { + const memoryName = this.$createMemoryNameInput.val().trim(); + + if (!memoryName) { + alert('Please enter a memory name'); + return; + } + + // Validate memory name (alphanumeric and underscores only) + if (!/^[a-zA-Z0-9_]+$/.test(memoryName)) { + alert('Memory name can only contain letters, numbers, and underscores'); + return; + } + + const self = this; + + // Disable button during request + self.$createMemoryCreateBtn.prop('disabled', true).text('Creating...'); + + $.ajax({ + url: '/save_memory', + type: 'POST', + contentType: 'application/json', + data: JSON.stringify({ + memory_name: memoryName, + content: '' + }), + success: function(response) { + if (response.status === 'success') { + // Close the create modal + self.closeCreateMemoryModal(); + // Reload config to show the new memory + self.loadConfigOverview(); + // Open the edit modal for the newly created memory + setTimeout(() => { + self.openEditMemoryModal(memoryName); + }, 500); + } else { + alert('Error: ' + response.message); + self.$createMemoryCreateBtn.prop('disabled', false).text('Create'); + } + }, + error: function(xhr, status, error) { + console.error('Error creating memory:', error); + alert('Error creating memory: ' + (xhr.responseJSON ? xhr.responseJSON.message : error)); + self.$createMemoryCreateBtn.prop('disabled', false).text('Create'); + } + }); + } + // ===== Shutdown Method ===== shutdown() { diff --git a/src/serena/resources/dashboard/index.html b/src/serena/resources/dashboard/index.html index 022e9f29..82e1239c 100644 --- a/src/serena/resources/dashboard/index.html +++ b/src/serena/resources/dashboard/index.html @@ -16,29 +16,29 @@ -
-
- +
+
+ +
+
-
+ +
+
@@ -58,6 +58,27 @@
Loading stats...
+ +
+

Executions Queue

+
+
Loading executions...
+
+
+ +
+

Last Execution

+
+
Loading...
+
+
+ +
@@ -106,11 +127,18 @@ @@ -159,12 +187,16 @@ it may take a few seconds before the LS is responsive.

- -
- +
@@ -178,7 +210,9 @@ Remove language from configuration?

- +
@@ -189,9 +223,12 @@ @@ -205,17 +242,64 @@ Delete memory ?

- +
- + - + + + + + + + + diff --git a/src/serena/task_executor.py b/src/serena/task_executor.py new file mode 100644 index 00000000..256fbf02 --- /dev/null +++ b/src/serena/task_executor.py @@ -0,0 +1,218 @@ +import concurrent.futures +import threading +import time +from collections.abc import Callable +from concurrent.futures import Future +from dataclasses import dataclass +from threading import Thread +from typing import Generic, TypeVar + +from sensai.util import logging +from sensai.util.logging import LogTime +from sensai.util.string import ToStringMixin + +log = logging.getLogger(__name__) +T = TypeVar("T") + + +class TaskExecutor: + def __init__(self, name: str): + self._task_executor_lock = threading.Lock() + self._task_executor_queue: list[TaskExecutor.Task] = [] + self._task_executor_thread = Thread(target=self._process_task_queue, name=name, daemon=True) + self._task_executor_thread.start() + self._task_executor_task_index = 1 + self._task_executor_current_task: TaskExecutor.Task | None = None + self._task_executor_last_executed_task_info: TaskExecutor.TaskInfo | None = None + + class Task(ToStringMixin, Generic[T]): + def __init__(self, function: Callable[[], T], name: str, logged: bool = True, timeout: float | None = None): + """ + :param function: the function representing the task to execute + :param name: the name of the task + :param logged: whether to log management of the task; if False, only errors will be logged + :param timeout: the maximum time to wait for task completion in seconds, or None to wait indefinitely + """ + self.name = name + self.future: concurrent.futures.Future = concurrent.futures.Future() + self.logged = logged + self.timeout = timeout + self._function = function + + def _tostring_includes(self) -> list[str]: + return ["name"] + + def start(self) -> None: + """ + Executes the task in a separate thread, setting the result or exception on the future. + """ + + def run_task() -> None: + try: + if self.future.done(): + if self.logged: + log.info(f"Task {self.name} was already completed/cancelled; skipping execution") + return + with LogTime(self.name, logger=log, enabled=self.logged): + result = self._function() + if not self.future.done(): + self.future.set_result(result) + except Exception as e: + if not self.future.done(): + log.error(f"Error during execution of {self.name}: {e}", exc_info=e) + self.future.set_exception(e) + + thread = Thread(target=run_task, name=self.name) + thread.start() + + def is_done(self) -> bool: + """ + :return: whether the task has completed (either successfully, with failure, or via cancellation) + """ + return self.future.done() + + def result(self, timeout: float | None = None) -> T: + """ + Blocks until the task is done or the timeout is reached, and returns the result. + If an exception occurred during task execution, it is raised here. + If the timeout is reached, a TimeoutError is raised (but the task is not cancelled). + If the task is cancelled, a CancelledError is raised. + + :param timeout: the maximum time to wait in seconds; if None, use the task's own timeout + (which may be None to wait indefinitely) + :return: True if the task is done, False if the timeout was reached + """ + return self.future.result(timeout=timeout) + + def cancel(self) -> None: + """ + Cancels the task. If it has not yet started, it will not be executed. + If it has already started, its future will be marked as cancelled and will raise a CancelledError + when its result is requested. + """ + self.future.cancel() + + def wait_until_done(self, timeout: float | None = None) -> None: + """ + Waits until the task is done or the timeout is reached. + The task is done if it either completed successfully, failed with an exception, or was cancelled. + + :param timeout: the maximum time to wait in seconds; if None, use the task's own timeout + (which may be None to wait indefinitely) + """ + try: + self.future.result(timeout=timeout) + except: + pass + + def _process_task_queue(self) -> None: + while True: + # obtain task from the queue + task: TaskExecutor.Task | None = None + with self._task_executor_lock: + if len(self._task_executor_queue) > 0: + task = self._task_executor_queue.pop(0) + if task is None: + time.sleep(0.1) + continue + + # start task execution asynchronously + with self._task_executor_lock: + self._task_executor_current_task = task + if task.logged: + log.info("Starting execution of %s", task.name) + task.start() + + # wait for task completion + task.wait_until_done(timeout=task.timeout) + with self._task_executor_lock: + self._task_executor_current_task = None + if task.logged: + self._task_executor_last_executed_task_info = self.TaskInfo.from_task(task, is_running=False) + + @dataclass + class TaskInfo: + name: str + is_running: bool + future: Future + """ + future for accessing the task's result + """ + task_id: int + """ + unique identifier of the task + """ + logged: bool + + def finished_successfully(self) -> bool: + return self.future.done() and not self.future.cancelled() and self.future.exception() is None + + @staticmethod + def from_task(task: "TaskExecutor.Task", is_running: bool) -> "TaskExecutor.TaskInfo": + return TaskExecutor.TaskInfo(name=task.name, is_running=is_running, future=task.future, task_id=id(task), logged=task.logged) + + def cancel(self) -> None: + self.future.cancel() + + def get_current_tasks(self) -> list[TaskInfo]: + """ + Gets the list of tasks currently running or queued for execution. + The function returns a list of thread-safe TaskInfo objects (specifically created for the caller). + + :return: the list of tasks in the execution order (running task first) + """ + tasks = [] + with self._task_executor_lock: + if self._task_executor_current_task is not None: + tasks.append(self.TaskInfo.from_task(self._task_executor_current_task, True)) + for task in self._task_executor_queue: + if not task.is_done(): + tasks.append(self.TaskInfo.from_task(task, False)) + return tasks + + def issue_task(self, task: Callable[[], T], name: str | None = None, logged: bool = True, timeout: float | None = None) -> Task[T]: + """ + Issue a task to the executor for asynchronous execution. + It is ensured that tasks are executed in the order they are issued, one after another. + + :param task: the task to execute + :param name: the name of the task for logging purposes; if None, use the task function's name + :param logged: whether to log management of the task; if False, only errors will be logged + :param timeout: the maximum time to wait for task completion in seconds, or None to wait indefinitely + :return: the task object, through which the task's future result can be accessed + """ + with self._task_executor_lock: + if logged: + task_prefix_name = f"Task-{self._task_executor_task_index}" + self._task_executor_task_index += 1 + else: + task_prefix_name = "BackgroundTask" + task_name = f"{task_prefix_name}:{name or task.__name__}" + if logged: + log.info(f"Scheduling {task_name}") + task_obj = self.Task(function=task, name=task_name, logged=logged, timeout=timeout) + self._task_executor_queue.append(task_obj) + return task_obj + + def execute_task(self, task: Callable[[], T], name: str | None = None, logged: bool = True, timeout: float | None = None) -> T: + """ + Executes the given task synchronously via the agent's task executor. + This is useful for tasks that need to be executed immediately and whose results are needed right away. + + :param task: the task to execute + :param name: the name of the task for logging purposes; if None, use the task function's name + :param logged: whether to log management of the task; if False, only errors will be logged + :param timeout: the maximum time to wait for task completion in seconds, or None to wait indefinitely + :return: the result of the task execution + """ + task_obj = self.issue_task(task, name=name, logged=logged, timeout=timeout) + return task_obj.result() + + def get_last_executed_task(self) -> TaskInfo | None: + """ + Gets information about the last executed task. + + :return: TaskInfo of the last executed task, or None if no task has been executed yet. + """ + with self._task_executor_lock: + return self._task_executor_last_executed_task_info diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index c4b0f300..a79f2911 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -286,8 +286,14 @@ class Tool(Component): return result - future = self.agent.issue_task(task, name=self.__class__.__name__) - return future.result(timeout=self.agent.serena_config.tool_timeout) + # execute the tool in the agent's task executor, with timeout + try: + task_exec = self.agent.issue_task(task, name=self.__class__.__name__) + return task_exec.result(timeout=self.agent.serena_config.tool_timeout) + except Exception as e: # typically TimeoutError (other exceptions caught in task) + msg = f"Error: {e.__class__.__name__} - {e}" + log.error(msg) + return msg class EditedFileContext: diff --git a/test/serena/test_task_executor.py b/test/serena/test_task_executor.py new file mode 100644 index 00000000..25c31c9d --- /dev/null +++ b/test/serena/test_task_executor.py @@ -0,0 +1,119 @@ +import time + +import pytest + +from serena.task_executor import TaskExecutor + + +@pytest.fixture +def executor(): + """ + Fixture for a basic SerenaAgent without a project + """ + return TaskExecutor("TestExecutor") + + +class Task: + def __init__(self, delay: float, exception: bool = False): + self.delay = delay + self.exception = exception + self.did_run = False + + def run(self): + self.did_run = True + time.sleep(self.delay) + if self.exception: + raise ValueError("Task failed") + return True + + +def test_task_executor_sequence(executor): + """ + Tests that a sequence of tasks is executed correctly + """ + future1 = executor.issue_task(Task(1).run, name="task1") + future2 = executor.issue_task(Task(1).run, name="task2") + assert future1.result() is True + assert future2.result() is True + + +def test_task_executor_exception(executor): + """ + Tests that tasks that raise exceptions are handled correctly, i.e. that + * the exception is propagated, + * subsequent tasks are still executed. + """ + future1 = executor.issue_task(Task(1, exception=True).run, name="task1") + future2 = executor.issue_task(Task(1).run, name="task2") + have_exception = False + try: + assert future1.result() + except Exception as e: + assert isinstance(e, ValueError) + have_exception = True + assert have_exception + assert future2.result() is True + + +def test_task_executor_cancel_current(executor): + """ + Tests that tasks that are cancelled are handled correctly, i.e. that + * subsequent tasks are executed as soon as cancellation ensues. + * the cancelled task raises CancelledError when result() is called. + """ + start_time = time.time() + future1 = executor.issue_task(Task(10).run, name="task1") + future2 = executor.issue_task(Task(1).run, name="task2") + time.sleep(1) + future1.cancel() + assert future2.result() is True + end_time = time.time() + assert (end_time - start_time) < 9, "Cancelled task did not stop in time" + have_cancelled_error = False + try: + future1.result() + except Exception as e: + assert e.__class__.__name__ == "CancelledError" + have_cancelled_error = True + assert have_cancelled_error + + +def test_task_executor_cancel_future(executor): + """ + Tests that when a future task is cancelled, it is never run at all + """ + task1 = Task(10) + task2 = Task(1) + future1 = executor.issue_task(task1.run, name="task1") + future2 = executor.issue_task(task2.run, name="task2") + time.sleep(1) + future2.cancel() + future1.cancel() + try: + future2.result() + except: + pass + assert task1.did_run + assert not task2.did_run + + +def test_task_executor_cancellation_via_task_info(executor): + start_time = time.time() + executor.issue_task(Task(10).run, "task1") + executor.issue_task(Task(1).run, "task2") + task_infos = executor.get_current_tasks() + task_infos2 = executor.get_current_tasks() + + # test expected tasks + assert len(task_infos) == 2 + assert "task1" in task_infos[0].name + assert "task2" in task_infos[1].name + + # test task identifiers being stable + assert task_infos2[0].task_id == task_infos[0].task_id + + # test cancellation + task_infos[0].cancel() + assert task_infos[1].future.result() + end_time = time.time() + assert (end_time - start_time) < 9, "Cancelled task did not stop in time"