Merge pull request #717 from oraios/dashboard_extensions

Dashboard extensions, in particular task queue management
This commit is contained in:
Michael Panchenko authored and GitHub committed 2025-10-31 14:09:27 +01:00
commit fe824a15e9
9 files changed
+1271 -141

No files matched your search

-1
View File
@@ -60,6 +60,5 @@ initial_prompt: ""
project_name: "serena"
languages:
- python
- typescript
included_optional_tools: []
encoding: utf-8
+29 -47
View File
@@ -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:
"""
+96 -31
View File
@@ -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
+224 -1
View File
@@ -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;
}
+454 -20
View File
@@ -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('<div class="error-message">Error loading tools</div>');
self.$availableModesDisplay.html('<div class="error-message">Error loading modes</div>');
self.$availableContextsDisplay.html('<div class="error-message">Error loading contexts</div>');
},
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 += '</div>';
html += '</div>';
// 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 += '<div style="margin-top: 20px;">';
html += '<h3 class="collapsible-header" id="memories-header" style="font-size: 16px; margin: 0;">';
html += '<span>Available Memories (' + config.available_memories.length + ')</span>';
const memoryCount = (config.available_memories && config.available_memories.length) || 0;
html += '<span>Available Memories (' + memoryCount + ')</span>';
html += '<span class="toggle-icon' + (wasMemoriesExpanded ? ' expanded' : '') + '">▼</span>';
html += '</h3>';
html += '<div class="collapsible-content memories-container" id="memories-content" style="' + (wasMemoriesExpanded ? '' : 'display:none;') + ' margin-top: 10px;">';
config.available_memories.forEach(function(memory) {
html += '<div class="memory-item removable" data-memory="' + memory + '">';
html += memory;
html += '<span class="memory-remove" data-memory="' + memory + '">&times;</span>';
html += '</div>';
});
if (config.available_memories && config.available_memories.length > 0) {
config.available_memories.forEach(function(memory) {
html += '<div class="memory-item removable" data-memory="' + memory + '">';
html += memory;
html += '<span class="memory-remove" data-memory="' + memory + '">&times;</span>';
html += '</div>';
});
}
// Add Create Memory button
html += '<button id="create-memory-btn" class="btn language-add-btn">+ Create Memory</button>';
html += '</div>';
html += '</div>';
}
@@ -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('<div class="error-message">Error loading executions</div>');
}
});
}
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('<div class="error-message">Error loading last execution</div>');
}
});
}
displayActiveExecutionsQueue(executions) {
if (!executions || executions.length === 0) {
return;
}
let html = '<div class="execution-list">';
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, '&#39;');
html += '<div class="' + itemClass + '" data-task-id="' + execution.task_id + '" data-execution=\'' + executionJson + '\'>';
if (isRunning) {
html += '<div class="execution-spinner"></div>';
}
html += '<div class="execution-name">' + self.escapeHtml(execution.name) + '</div>';
if (isRunning) {
html += '<div class="execution-meta">#' + execution.task_id + '</div>';
} else {
html += '<div class="execution-meta">queued · #' + execution.task_id + '</div>';
}
html += '<button class="execution-cancel-btn" data-task-id="' + execution.task_id + '" data-is-running="' + isRunning + '">✕</button>';
html += '</div>';
});
html += '</div>';
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(/&#39;/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('<div class="no-stats-message">No executions yet.</div>');
return;
}
const isSuccess = execution.finished_successfully;
let html = '<div class="last-execution-container' + (isSuccess ? '' : ' error') + '">';
html += '<div class="last-execution-icon-container">';
html += isSuccess ? '✓' : '✕';
html += '</div>';
html += '<div class="last-execution-body">';
html += '<div class="last-execution-status">' + (isSuccess ? 'Succeeded' : 'Failed') + '</div>';
html += '<div class="last-execution-name">' + this.escapeHtml(execution.name) + '</div>';
html += '</div>';
html += '<div class="execution-meta">#' + execution.task_id + '</div>';
html += '</div>';
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 = '<div class="execution-list">';
cancelledExecs.forEach(function(execution) {
const isAbandoned = execution.is_running;
html += '<div class="execution-item ' + (isAbandoned ? 'abandoned' : 'cancelled') + '">';
html += '<div class="execution-icon ' + (isAbandoned ? 'abandoned' : 'cancelled') + '">';
html += isAbandoned ? '!' : '✕';
html += '</div>';
html += '<div class="execution-name">' + self.escapeHtml(execution.name) + '</div>';
html += '<div class="execution-meta">' + (isAbandoned ? 'abandoned · ' : '') + '#' + execution.task_id + '</div>';
html += '</div>';
});
html += '</div>';
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 = {
'<': '&lt;',
'>': '&gt;',
'&': '&amp;',
'"': '&quot;',
"'": '&#x27;',
'`': '&#x60;'
};
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 = '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg><span class="copy-logs-text">copy logs</span>';
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('<div class="error-message">Error loading logs: ' +
(xhr.responseJSON ? xhr.responseJSON.detail : error) + '</div>');
},
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() {
+123 -39
View File
@@ -16,29 +16,29 @@
</head>
<body>
<header class="header">
<div class="logo-container">
<img id="serena-logo" src="serena-logs.png" alt="Serena">
<header class="header">
<div class="logo-container">
<img id="serena-logo" src="serena-logs.png" alt="Serena">
</div>
<nav class="header-nav">
<button id="theme-toggle" class="theme-toggle">
<span id="theme-icon">🌙</span>
<span id="theme-text">Dark</span>
</button>
<button id="menu-toggle" class="menu-button">
<span>☰</span>
<span>Menu</span>
</button>
<div id="menu-dropdown" class="menu-dropdown" style="display:none">
<a href="#" data-page="overview" class="active">Overview</a>
<a href="#" data-page="logs">Logs</a>
<a href="#" data-page="stats">Advanced Stats</a>
<hr>
<a href="#" id="menu-shutdown">Shutdown Server</a>
</div>
<nav class="header-nav">
<button id="theme-toggle" class="theme-toggle">
<span id="theme-icon">🌙</span>
<span id="theme-text">Dark</span>
</button>
<button id="menu-toggle" class="menu-button">
<span>☰</span>
<span>Menu</span>
</button>
<div id="menu-dropdown" class="menu-dropdown" style="display:none">
<a href="#" data-page="overview" class="active">Overview</a>
<a href="#" data-page="logs">Logs</a>
<a href="#" data-page="stats">Advanced Stats</a>
<hr>
<a href="#" id="menu-shutdown">Shutdown Server</a>
</div>
</nav>
</header>
<div class="main">
</nav>
</header>
<div class="main">
<!-- Overview Page (Landing) -->
<div id="page-overview" class="page-view">
<div class="overview-container">
@@ -58,6 +58,27 @@
<div class="loading">Loading stats...</div>
</div>
</section>
<section class="executions-section">
<h2>Executions Queue</h2>
<div id="active-executions-display">
<div class="loading">Loading executions...</div>
</div>
</section>
<section class="executions-section">
<h2>Last Execution</h2>
<div id="last-execution-display">
<div class="loading">Loading...</div>
</div>
</section>
<section class="executions-section" style="display:none">
<h2>Cancelled Executions</h2>
<div id="cancelled-executions-display">
<div class="no-stats-message">No cancelled executions.</div>
</div>
</section>
</div>
<div class="overview-right">
@@ -106,11 +127,18 @@
<!-- Logs Page -->
<div id="page-logs" class="page-view" style="display:none">
<div class="controls">
<button id="load-logs" class="btn">Reload Log</button>
</div>
<div id="error-container"></div>
<div id="log-container" class="log-container"></div>
<div style="position: relative;">
<button id="copy-logs-btn" class="copy-logs-btn" title="Copy logs">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span class="copy-logs-text">copy logs</span>
</button>
<div id="log-container" class="log-container"></div>
</div>
</div>
<!-- Advanced Stats Page -->
@@ -159,12 +187,16 @@
it may take a few seconds before the LS is responsive.
</p>
<div style="margin: 20px 0;">
<label for="modal-language-select" style="display: block; margin-bottom: 8px; font-weight: 500;">Select Language:</label>
<select id="modal-language-select" class="language-select" style="width: 100%; padding: 8px 10px; border-radius: 4px; border: 1px solid var(--border-color); background: var(--bg-primary); color: var(--text-primary); font-size: 14px;">
<label for="modal-language-select" style="display: block; margin-bottom: 8px; font-weight: 500;">Select
Language:</label>
<select id="modal-language-select" class="language-select"
style="width: 100%; padding: 8px 10px; border-radius: 4px; border: 1px solid var(--border-color); background: var(--bg-primary); color: var(--text-primary); font-size: 14px;">
</select>
</div>
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px;">
<button id="modal-cancel-btn" class="btn" style="background: var(--bg-secondary); color: var(--text-primary); padding: 8px 16px;">Cancel</button>
<button id="modal-cancel-btn" class="btn"
style="background: var(--bg-secondary); color: var(--text-primary); padding: 8px 16px;">Cancel
</button>
<button id="modal-add-btn" class="btn" style="padding: 8px 16px;">Add Language</button>
</div>
</div>
@@ -178,7 +210,9 @@
Remove language <strong id="remove-language-name"></strong> from configuration?
</p>
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px;">
<button id="remove-modal-cancel-btn" class="btn" style="background: var(--bg-secondary); color: var(--text-primary); padding: 8px 16px;">Cancel</button>
<button id="remove-modal-cancel-btn" class="btn"
style="background: var(--bg-secondary); color: var(--text-primary); padding: 8px 16px;">Cancel
</button>
<button id="remove-modal-ok-btn" class="btn" style="padding: 8px 16px;">OK</button>
</div>
</div>
@@ -189,9 +223,12 @@
<div class="modal-content modal-content-large">
<span class="modal-close modal-close-edit-memory">&times;</span>
<h3 style="margin-bottom: 20px;">Memory: <span id="edit-memory-name"></span></h3>
<textarea id="edit-memory-content" class="memory-editor" rows="20" style="width: 100%; padding: 10px; border: 1px solid var(--border-color); border-radius: 4px; background: var(--bg-primary); color: var(--text-primary); font-family: 'Courier New', monospace; font-size: 13px; resize: vertical;"></textarea>
<textarea id="edit-memory-content" class="memory-editor" rows="20"
style="width: 100%; padding: 10px; border: 1px solid var(--border-color); border-radius: 4px; background: var(--bg-primary); color: var(--text-primary); font-family: 'Courier New', monospace; font-size: 13px; resize: vertical;"></textarea>
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px;">
<button id="edit-memory-cancel-btn" class="btn" style="background: var(--bg-secondary); color: var(--text-primary); padding: 8px 16px;">Cancel</button>
<button id="edit-memory-cancel-btn" class="btn"
style="background: var(--bg-secondary); color: var(--text-primary); padding: 8px 16px;">Cancel
</button>
<button id="edit-memory-save-btn" class="btn" style="padding: 8px 16px;">Save</button>
</div>
</div>
@@ -205,17 +242,64 @@
Delete memory <strong id="delete-memory-name"></strong>?
</p>
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px;">
<button id="delete-memory-cancel-btn" class="btn" style="background: var(--bg-secondary); color: var(--text-primary); padding: 8px 16px;">Cancel</button>
<button id="delete-memory-cancel-btn" class="btn"
style="background: var(--bg-secondary); color: var(--text-primary); padding: 8px 16px;">Cancel
</button>
<button id="delete-memory-ok-btn" class="btn" style="padding: 8px 16px;">OK</button>
</div>
</div>
</div> <!-- End of Main -->
</div>
<script>
$(document).ready(function () {
const dashboard = new Dashboard();
});
</script>
<!-- Create Memory Modal -->
<div id="create-memory-modal" class="modal" style="display:none">
<div class="modal-content">
<span class="modal-close modal-close-create-memory">&times;</span>
<p id="create-memory-project-info" style="margin: 15px 0; color: var(--text-primary); line-height: 1.5;">
Create a new memory for project <strong id="create-memory-project-name"></strong>.
</p>
<p style="margin: 15px 0; color: var(--text-muted); font-size: 13px; line-height: 1.5;">
Memory names should be descriptive and use underscores instead of spaces (e.g., "api_architecture",
"testing_guidelines").
</p>
<div style="margin: 20px 0;">
<label for="create-memory-name-input" style="display: block; margin-bottom: 8px; font-weight: 500;">Memory
Name:</label>
<input type="text" id="create-memory-name-input" class="memory-name-input"
placeholder="e.g., project_overview"
style="width: 100%; padding: 8px 10px; border-radius: 4px; border: 1px solid var(--border-color); background: var(--bg-primary); color: var(--text-primary); font-size: 14px;">
</div>
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px;">
<button id="create-memory-cancel-btn" class="btn"
style="background: var(--bg-secondary); color: var(--text-primary); padding: 8px 16px;">Cancel
</button>
<button id="create-memory-create-btn" class="btn" style="padding: 8px 16px;">Create</button>
</div>
</div>
</div>
<!-- Cancel Execution Confirmation Modal -->
<div id="cancel-execution-modal" class="modal" style="display:none">
<div class="modal-content">
<span class="modal-close modal-close-cancel-execution">&times;</span>
<p style="margin: 20px 0; color: var(--text-primary); line-height: 1.5; font-size: 15px;">
Are you sure? The execution will continue running until timeout, it will simply no longer be in the queue.
Abandoning a running execution is only advised as a measure for unblocking Serena.
</p>
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px;">
<button id="cancel-execution-cancel-btn" class="btn"
style="background: var(--bg-secondary); color: var(--text-primary); padding: 8px 16px;">Cancel
</button>
<button id="cancel-execution-ok-btn" class="btn" style="padding: 8px 16px;">OK</button>
</div>
</div>
</div>
</div> <!-- End of Main -->
<script>
$(document).ready(function () {
const dashboard = new Dashboard();
});
</script>
</body>
</html>
+218
View File
@@ -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
+8 -2
View File
@@ -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:
+119
View File
@@ -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"