Feature: cancelling executions through dashboard

This commit is contained in:
MischaPanch committed 2025-10-31 13:04:53 +01:00
1 parent 99d75be125
commit 322348ea51
7 files changed
+581 -11

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
+9
View File
@@ -198,6 +198,15 @@ class SerenaAgent:
"""
return self._task_executor.get_current_tasks()
def cancel_task(self, task_id: int) -> bool:
"""
Cancels a task with the given ID and removes it from the task queue.
:param task_id: the ID of the task to cancel
:return: True if the task was found and cancelled, False otherwise
"""
return self._task_executor.cancel_task(task_id)
def get_last_executed_task(self) -> TaskExecutor.TaskInfo | None:
"""
Gets the last executed task.
+21 -5
View File
@@ -86,11 +86,16 @@ 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:
@@ -99,6 +104,7 @@ class QueuedExecution(BaseModel):
is_running=task_info.is_running,
name=task_info.name,
finished_successfully=task_info.finished_successfully(),
logged=task_info.logged,
)
@@ -169,7 +175,7 @@ class SerenaDashboardAPI:
@self._app.route("/get_config_overview", methods=["GET"])
def get_config_overview() -> dict[str, Any]:
result = self._agent.execute_task(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"])
@@ -251,6 +257,16 @@ class SerenaDashboardAPI:
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)
task_was_cancelled = self._agent.cancel_task(request_cancel_task.task_id)
return {"status": "success", "was_cancelled": task_was_cancelled}
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:
@@ -417,7 +433,7 @@ class SerenaDashboardAPI:
return ResponseAvailableLanguages(languages=sorted(available_languages))
return self._agent.execute_task(run)
return self._agent.execute_task(run, logged=False)
def _get_memory(self, request_get_memory: RequestGetMemory) -> ResponseGetMemory:
def run() -> ResponseGetMemory:
@@ -428,7 +444,7 @@ class SerenaDashboardAPI:
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)
return self._agent.execute_task(run, logged=False)
def _save_memory(self, request_save_memory: RequestSaveMemory) -> None:
def run() -> None:
@@ -438,7 +454,7 @@ class SerenaDashboardAPI:
project.memories_manager.save_memory(request_save_memory.memory_name, request_save_memory.content)
self._agent.execute_task(run)
self._agent.execute_task(run, logged=True, name="SaveMemory")
def _delete_memory(self, request_delete_memory: RequestDeleteMemory) -> None:
def run() -> None:
@@ -448,7 +464,7 @@ class SerenaDashboardAPI:
project.memories_manager.delete_memory(request_delete_memory.memory_name)
self._agent.execute_task(run)
self._agent.execute_task(run, logged=True, name="DeleteMemory")
def _add_language(self, request_add_language: RequestAddLanguage) -> None:
from solidlsp.ls_config import Language
+185 -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);
}
+307 -3
View File
@@ -62,11 +62,16 @@ class Dashboard {
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
@@ -117,6 +122,13 @@ class Dashboard {
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;
@@ -156,6 +168,9 @@ class Dashboard {
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) {
@@ -237,6 +252,7 @@ class Dashboard {
// Start on overview page
self.loadConfigOverview();
self.startConfigPolling();
self.startExecutionsPolling();
});
}
@@ -268,6 +284,7 @@ class Dashboard {
if (page === 'overview') {
this.loadConfigOverview();
this.startConfigPolling();
this.startExecutionsPolling();
} else if (page === 'logs') {
this.loadLogs();
} else if (page === 'stats') {
@@ -284,6 +301,10 @@ class Dashboard {
clearInterval(this.configPollInterval);
this.configPollInterval = null;
}
if (this.executionsPollInterval) {
clearInterval(this.executionsPollInterval);
this.executionsPollInterval = null;
}
}
// ===== Config Overview Methods =====
@@ -299,7 +320,6 @@ class Dashboard {
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;
@@ -336,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
@@ -604,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) {
@@ -1207,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) {
@@ -1290,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();
+38
View File
@@ -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">
@@ -255,6 +276,23 @@
</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>
+21 -1
View File
@@ -142,13 +142,14 @@ class TaskExecutor:
"""
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))
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()
@@ -215,3 +216,22 @@ class TaskExecutor:
"""
with self._task_executor_lock:
return self._task_executor_last_executed_task_info
def cancel_task(self, task_id: int) -> bool:
"""
Cancels a task with the given task ID and removes it from the queue if found.
:param task_id: the unique identifier of the task to cancel
:return: True if the task was found and cancelled, False otherwise
"""
with self._task_executor_lock:
if self._task_executor_current_task is not None and id(self._task_executor_current_task) == task_id:
self._task_executor_current_task.cancel()
self._task_executor_current_task = None
return True
for task in self._task_executor_queue:
if id(task) == task_id:
task.cancel()
self._task_executor_queue.remove(task)
return True
raise KeyError(f"Task with ID {task_id} not found in the queue. {self._task_executor_queue=}, {self._task_executor_current_task=}")