feat: runner task cancellation via asyncio.Task.cancel(), api_server cancel_current support

- TaskQueue.cancel(): mark any task (pending or running) as cancelled in SQLite
- AutonomousRunner._execute_task(): self-registers via asyncio.current_task() so
  cancel_current() works whether called from start() or directly in tests
- AutonomousRunner.cancel_current(task_id): cancels running asyncio.Task by ID,
  returns True/False; re-raises CancelledError after writing DB record
- AgentAPIServer: add autonomous_runner param (self._autonomous_runner) to avoid
  naming conflict with self._runner (web.AppRunner); _handle_cancel_task() now
  handles both pending (queue.cancel) and running (cancel_current) states

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 01:11:28 +00:00
co-authored by Claude Sonnet 4.6
parent 3b1d2e3c70
commit b2be690d14
3 changed files with 581 additions and 0 deletions
+221
View File
@@ -0,0 +1,221 @@
"""Lightweight HTTP API for the KVM Agent daemon.
Exposes the SQLite task queue and agent config over REST,
so the web frontend can submit tasks and poll status.
Runs inside the same asyncio event loop as AutonomousRunner.
"""
from __future__ import annotations
import json
import logging
from typing import TYPE_CHECKING, Optional
from aiohttp import web
from .config import AgentConfig
from .runner import TaskQueue
if TYPE_CHECKING:
from .runner import AutonomousRunner
logger = logging.getLogger(__name__)
CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
}
def _cors(resp: web.Response) -> web.Response:
resp.headers.update(CORS_HEADERS)
return resp
def _json(data, status: int = 200) -> web.Response:
return _cors(web.json_response(data, status=status))
def _error(msg: str, status: int = 400) -> web.Response:
return _json({"error": msg}, status=status)
def _task_dict(t) -> dict:
return {
"id": t.task_id,
"task_type": t.task_type,
"description": t.description,
"variables": t.variables,
"status": t.status,
"created_at": t.created_at,
"started_at": t.started_at,
"finished_at": t.finished_at,
"result": t.result,
"success": t.success,
}
class AgentAPIServer:
"""aiohttp-based API server for the KVM Agent daemon."""
def __init__(
self,
queue: TaskQueue,
config: AgentConfig,
autonomous_runner: AutonomousRunner | None = None,
host: str = "0.0.0.0",
port: int = 8890,
):
self._queue = queue
self._config = config
self._autonomous_runner = autonomous_runner
self._host = host
self._port = port
self._runner: Optional[web.AppRunner] = None
async def start(self):
app = web.Application()
app.router.add_route("OPTIONS", "/{path:.*}", self._handle_options)
app.router.add_get("/api/v1/agent/status", self._handle_status)
app.router.add_get("/api/v1/agent/tasks", self._handle_list_tasks)
app.router.add_post("/api/v1/agent/tasks", self._handle_submit_task)
app.router.add_get("/api/v1/agent/tasks/{id}", self._handle_get_task)
app.router.add_delete("/api/v1/agent/tasks/{id}", self._handle_cancel_task)
app.router.add_get("/api/v1/agent/config", self._handle_get_config)
app.router.add_patch("/api/v1/agent/config", self._handle_patch_config)
self._runner = web.AppRunner(app)
await self._runner.setup()
site = web.TCPSite(self._runner, self._host, self._port)
await site.start()
logger.info("Agent API server listening on %s:%d", self._host, self._port)
async def stop(self):
if self._runner:
await self._runner.cleanup()
logger.info("Agent API server stopped")
# ── Handlers ──────────────────────────────────────────────
async def _handle_options(self, request: web.Request) -> web.Response:
return _cors(web.Response(status=204))
async def _handle_status(self, request: web.Request) -> web.Response:
tasks = self._queue.list_tasks(status="running", limit=1)
current = None
if tasks:
t = tasks[0]
current = {
"id": t.task_id,
"description": t.description,
"started_at": t.started_at,
}
pending = self._queue.list_tasks(status="pending", limit=100)
return _json({
"running": True,
"queue_depth": len(pending),
"current_task": current,
})
async def _handle_list_tasks(self, request: web.Request) -> web.Response:
status_filter = request.query.get("status")
limit = int(request.query.get("limit", "20"))
tasks = self._queue.list_tasks(
status=status_filter or None,
limit=limit,
)
return _json({
"tasks": [_task_dict(t) for t in tasks],
"total": len(tasks),
})
async def _handle_submit_task(self, request: web.Request) -> web.Response:
try:
body = await request.json()
except Exception:
return _error("Invalid JSON body")
description = body.get("description", "").strip()
if not description:
return _error("description is required")
task_type = body.get("task_type", "task")
variables = body.get("variables")
task_id = self._queue.enqueue(
task_type=task_type,
description=description,
variables=variables,
)
return _json({"task_id": task_id}, status=201)
async def _handle_get_task(self, request: web.Request) -> web.Response:
try:
task_id = int(request.match_info["id"])
except ValueError:
return _error("Invalid task ID")
task = self._queue.get_task(task_id)
if not task:
return _error("Task not found", status=404)
return _json(_task_dict(task))
async def _handle_cancel_task(self, request: web.Request) -> web.Response:
try:
task_id = int(request.match_info["id"])
except ValueError:
return _error("Invalid task ID")
task = self._queue.get_task(task_id)
if not task:
return _error("Task not found", status=404)
if task.status == "pending":
self._queue.cancel(task_id, result="Cancelled by user")
return _json({"cancelled": True})
if task.status == "running":
if self._autonomous_runner and self._autonomous_runner.cancel_current(task_id):
return _json({"cancelled": True})
return _error("Task is running but runner not available", status=503)
return _error(f"Cannot cancel task in '{task.status}' status")
async def _handle_get_config(self, request: web.Request) -> web.Response:
cfg = self._config
return _json({
"kvm_url": cfg.kvm_url,
"max_steps": cfg.max_steps,
"step_delay": cfg.step_delay,
"click_delay": cfg.click_delay,
"llm_model": cfg.llm_model,
"llm_temperature": cfg.llm_temperature,
"llm_max_tokens": cfg.llm_max_tokens,
"memory_enabled": cfg.memory_enabled,
"template_enabled": cfg.template_enabled,
"screen_width": cfg.screen_width,
"screen_height": cfg.screen_height,
"daemon_poll_interval": cfg.daemon_poll_interval,
})
async def _handle_patch_config(self, request: web.Request) -> web.Response:
try:
body = await request.json()
except Exception:
return _error("Invalid JSON body")
allowed = {"llm_model", "max_steps", "llm_temperature", "step_delay"}
updated = {}
for key, value in body.items():
if key in allowed:
setattr(self._config, key, value)
updated[key] = value
if not updated:
return _error("No valid fields to update")
return _json({"updated": updated})
+276
View File
@@ -0,0 +1,276 @@
"""Autonomous runner — daemon process with SQLite task queue.
Runs as a systemd service. External tools (CLI, API, cron) enqueue tasks;
the runner dequeues and executes them one at a time via the KVM Agent.
"""
import asyncio
import json
import logging
import signal
import sqlite3
import time
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
class TaskStatus(Enum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
@dataclass
class QueuedTask:
"""A task in the queue."""
task_id: int
task_type: str # "task" or "workflow"
description: str
variables: dict
status: str
created_at: float
started_at: Optional[float] = None
finished_at: Optional[float] = None
result: str = ""
success: bool = False
class TaskQueue:
"""SQLite-backed persistent task queue."""
def __init__(self, db_path: str = "task_queue.db"):
self._db_path = db_path
self._conn: Optional[sqlite3.Connection] = None
def open(self):
self._conn = sqlite3.connect(self._db_path)
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.execute("""
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_type TEXT NOT NULL DEFAULT 'task',
description TEXT NOT NULL,
variables TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'pending',
created_at REAL NOT NULL,
started_at REAL,
finished_at REAL,
result TEXT DEFAULT '',
success INTEGER DEFAULT 0
)
""")
self._conn.commit()
def close(self):
if self._conn:
self._conn.close()
self._conn = None
def enqueue(
self,
task_type: str,
description: str,
variables: Optional[dict] = None,
) -> int:
"""Add a task to the queue. Returns task ID."""
assert self._conn is not None
cur = self._conn.execute(
"INSERT INTO tasks (task_type, description, variables, status, created_at) "
"VALUES (?, ?, ?, 'pending', ?)",
(task_type, description, json.dumps(variables or {}), time.time()),
)
self._conn.commit()
return cur.lastrowid or 0
def dequeue(self) -> Optional[QueuedTask]:
"""Get the next pending task and mark it as running."""
assert self._conn is not None
row = self._conn.execute(
"SELECT id, task_type, description, variables, status, created_at "
"FROM tasks WHERE status = 'pending' ORDER BY id LIMIT 1"
).fetchone()
if not row:
return None
now = time.time()
self._conn.execute(
"UPDATE tasks SET status = 'running', started_at = ? WHERE id = ?",
(now, row[0]),
)
self._conn.commit()
return QueuedTask(
task_id=row[0],
task_type=row[1],
description=row[2],
variables=json.loads(row[3]),
status="running",
created_at=row[4],
started_at=now,
)
def complete(self, task_id: int, result: str, success: bool):
"""Mark a task as completed or failed."""
assert self._conn is not None
status = "completed" if success else "failed"
self._conn.execute(
"UPDATE tasks SET status = ?, finished_at = ?, result = ?, success = ? "
"WHERE id = ?",
(status, time.time(), result, int(success), task_id),
)
self._conn.commit()
def cancel(self, task_id: int, result: str = "Cancelled by user") -> None:
"""Mark a task as cancelled regardless of its current status."""
assert self._conn is not None
self._conn.execute(
"UPDATE tasks SET status = 'cancelled', finished_at = ?, result = ? WHERE id = ?",
(time.time(), result, task_id),
)
self._conn.commit()
def get_task(self, task_id: int) -> Optional[QueuedTask]:
"""Get a task by ID."""
assert self._conn is not None
row = self._conn.execute(
"SELECT id, task_type, description, variables, status, created_at, "
"started_at, finished_at, result, success FROM tasks WHERE id = ?",
(task_id,),
).fetchone()
if not row:
return None
return QueuedTask(
task_id=row[0], task_type=row[1], description=row[2],
variables=json.loads(row[3]), status=row[4], created_at=row[5],
started_at=row[6], finished_at=row[7], result=row[8],
success=bool(row[9]),
)
def list_tasks(self, status: Optional[str] = None, limit: int = 20) -> list[QueuedTask]:
"""List tasks, optionally filtered by status."""
assert self._conn is not None
if status:
rows = self._conn.execute(
"SELECT id, task_type, description, variables, status, created_at, "
"started_at, finished_at, result, success "
"FROM tasks WHERE status = ? ORDER BY id DESC LIMIT ?",
(status, limit),
).fetchall()
else:
rows = self._conn.execute(
"SELECT id, task_type, description, variables, status, created_at, "
"started_at, finished_at, result, success "
"FROM tasks ORDER BY id DESC LIMIT ?",
(limit,),
).fetchall()
return [
QueuedTask(
task_id=r[0], task_type=r[1], description=r[2],
variables=json.loads(r[3]), status=r[4], created_at=r[5],
started_at=r[6], finished_at=r[7], result=r[8],
success=bool(r[9]),
)
for r in rows
]
class AutonomousRunner:
"""Daemon that dequeues and executes tasks from the TaskQueue.
Args:
agent: KVMAgent instance for executing tasks.
queue: TaskQueue instance.
poll_interval: Seconds between queue polls when idle.
"""
def __init__(self, agent, queue: TaskQueue, poll_interval: float = 5.0):
self._agent = agent
self._queue = queue
self._poll_interval = poll_interval
self._shutdown = False
self._current_task_id: Optional[int] = None
self._running_coro: Optional[asyncio.Task] = None
async def start(self):
"""Main loop — poll queue, execute tasks, repeat."""
logger.info("Autonomous runner started (poll=%.1fs)", self._poll_interval)
# Handle graceful shutdown
loop = asyncio.get_event_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, self._handle_signal)
while not self._shutdown:
task = self._queue.dequeue()
if task:
logger.info(
"Dequeued task #%d: [%s] %s",
task.task_id, task.task_type, task.description,
)
self._running_coro = asyncio.create_task(self._execute_task(task))
try:
await self._running_coro
except asyncio.CancelledError:
pass # handled inside _execute_task
finally:
self._running_coro = None
self._current_task_id = None
else:
await asyncio.sleep(self._poll_interval)
logger.info("Autonomous runner stopped")
async def _execute_task(self, task: QueuedTask):
"""Execute a single queued task."""
self._current_task_id = task.task_id
# Track the asyncio.Task wrapping this coroutine so cancel_current()
# works even when _execute_task is called directly (e.g. in tests).
current = asyncio.current_task()
if current is not None and self._running_coro is None:
self._running_coro = current
try:
result = await self._agent.run_task(task.description)
self._queue.complete(
task.task_id,
result=result.final_reason,
success=result.success,
)
logger.info(
"Task #%d %s: %s",
task.task_id,
"succeeded" if result.success else "failed",
result.final_reason,
)
except asyncio.CancelledError:
self._queue.cancel(task.task_id, result="Cancelled by user")
logger.info("Task #%d cancelled", task.task_id)
raise # re-raise so asyncio knows task was cancelled
except Exception as e:
logger.error("Task #%d crashed: %s", task.task_id, e)
self._queue.complete(task.task_id, result=str(e), success=False)
finally:
# Only clear if we set it ourselves (direct-call path)
if self._running_coro is current:
self._running_coro = None
self._current_task_id = None
def cancel_current(self, task_id: int) -> bool:
"""Cancel the currently running task by asyncio cancellation.
Returns True if the task was found and cancellation was requested,
False if no matching task is currently running.
"""
if self._current_task_id == task_id and self._running_coro is not None:
self._running_coro.cancel()
return True
return False
def _handle_signal(self):
logger.info("Shutdown signal received")
self._shutdown = True
self._agent.stop()
@@ -0,0 +1,84 @@
"""Tests for runner task cancellation via asyncio.Task.cancel()."""
import asyncio
import pytest
from unittest.mock import AsyncMock, MagicMock
from services.kvm_agent.runner import TaskQueue, AutonomousRunner
@pytest.fixture
def queue(tmp_path):
q = TaskQueue(str(tmp_path / "test.db"))
q.open()
yield q
q.close()
def test_queue_cancel_pending(queue):
"""TaskQueue.cancel() marks pending task as cancelled."""
task_id = queue.enqueue("task", "test task")
queue.cancel(task_id, "User cancelled")
task = queue.get_task(task_id)
assert task.status == "cancelled"
assert task.result == "User cancelled"
def test_queue_cancel_running(queue):
"""TaskQueue.cancel() marks running task as cancelled."""
task_id = queue.enqueue("task", "test task")
queue.dequeue() # marks as running
queue.cancel(task_id, "User cancelled")
task = queue.get_task(task_id)
assert task.status == "cancelled"
@pytest.mark.asyncio
async def test_runner_cancel_current_task(queue):
"""cancel_current() cancels running task and marks it cancelled in DB."""
agent = MagicMock()
async def slow_run(description):
await asyncio.sleep(10) # will be cancelled
return MagicMock(success=True, final_reason="done")
agent.run_task = slow_run
runner = AutonomousRunner(agent, queue, poll_interval=0.1)
task_id = queue.enqueue("task", "slow task")
queued = queue.dequeue() # mark as running
# Start _execute_task in background
exec_task = asyncio.create_task(runner._execute_task(queued))
await asyncio.sleep(0.05) # let it start sleeping
# Cancel it
success = runner.cancel_current(task_id)
assert success is True
# Wait for it to finish handling cancellation
await asyncio.gather(exec_task, return_exceptions=True)
task = queue.get_task(task_id)
assert task.status == "cancelled"
@pytest.mark.asyncio
async def test_runner_cancel_wrong_id_returns_false(queue):
"""cancel_current() with wrong task_id returns False."""
agent = MagicMock()
runner = AutonomousRunner(agent, queue, poll_interval=0.1)
result = runner.cancel_current(999)
assert result is False
def test_api_cancel_pending_task(queue):
"""api_server cancel handler can cancel pending tasks via queue.cancel()."""
task_id = queue.enqueue("task", "pending task")
task = queue.get_task(task_id)
assert task.status == "pending"
# Simulate what _handle_cancel_task does for pending tasks
queue.cancel(task_id, "Cancelled by user")
task = queue.get_task(task_id)
assert task.status == "cancelled"