diff --git a/src/dashboard/routes/tasks.py b/src/dashboard/routes/tasks.py
index 69f2fc72..4b9c0841 100644
--- a/src/dashboard/routes/tasks.py
+++ b/src/dashboard/routes/tasks.py
@@ -104,25 +104,29 @@ class _TaskView:
@router.get("/tasks", response_class=HTMLResponse)
async def tasks_page(request: Request):
"""Render the main task queue page with 3-column layout."""
- with _get_db() as db:
- pending = [
- _TaskView(_row_to_dict(r))
- for r in db.execute(
- "SELECT * FROM tasks WHERE status IN ('pending_approval') ORDER BY created_at DESC"
- ).fetchall()
- ]
- active = [
- _TaskView(_row_to_dict(r))
- for r in db.execute(
- "SELECT * FROM tasks WHERE status IN ('approved','running','paused') ORDER BY created_at DESC"
- ).fetchall()
- ]
- completed = [
- _TaskView(_row_to_dict(r))
- for r in db.execute(
- "SELECT * FROM tasks WHERE status IN ('completed','vetoed','failed') ORDER BY completed_at DESC LIMIT 50"
- ).fetchall()
- ]
+ try:
+ with _get_db() as db:
+ pending = [
+ _TaskView(_row_to_dict(r))
+ for r in db.execute(
+ "SELECT * FROM tasks WHERE status IN ('pending_approval') ORDER BY created_at DESC"
+ ).fetchall()
+ ]
+ active = [
+ _TaskView(_row_to_dict(r))
+ for r in db.execute(
+ "SELECT * FROM tasks WHERE status IN ('approved','running','paused') ORDER BY created_at DESC"
+ ).fetchall()
+ ]
+ completed = [
+ _TaskView(_row_to_dict(r))
+ for r in db.execute(
+ "SELECT * FROM tasks WHERE status IN ('completed','vetoed','failed') ORDER BY completed_at DESC LIMIT 50"
+ ).fetchall()
+ ]
+ except sqlite3.OperationalError as exc:
+ logger.warning("Task DB unavailable: %s", exc)
+ pending, active, completed = [], [], []
return templates.TemplateResponse(
request,
@@ -146,10 +150,14 @@ async def tasks_page(request: Request):
@router.get("/tasks/pending", response_class=HTMLResponse)
async def tasks_pending(request: Request):
"""Return HTMX partial for pending approval tasks."""
- with _get_db() as db:
- rows = db.execute(
- "SELECT * FROM tasks WHERE status='pending_approval' ORDER BY created_at DESC"
- ).fetchall()
+ try:
+ with _get_db() as db:
+ rows = db.execute(
+ "SELECT * FROM tasks WHERE status='pending_approval' ORDER BY created_at DESC"
+ ).fetchall()
+ except sqlite3.OperationalError as exc:
+ logger.warning("Task DB unavailable: %s", exc)
+ return HTMLResponse('
Database unavailable
')
tasks = [_TaskView(_row_to_dict(r)) for r in rows]
parts = []
for task in tasks:
@@ -166,10 +174,14 @@ async def tasks_pending(request: Request):
@router.get("/tasks/active", response_class=HTMLResponse)
async def tasks_active(request: Request):
"""Return HTMX partial for active (approved/running/paused) tasks."""
- with _get_db() as db:
- rows = db.execute(
- "SELECT * FROM tasks WHERE status IN ('approved','running','paused') ORDER BY created_at DESC"
- ).fetchall()
+ try:
+ with _get_db() as db:
+ rows = db.execute(
+ "SELECT * FROM tasks WHERE status IN ('approved','running','paused') ORDER BY created_at DESC"
+ ).fetchall()
+ except sqlite3.OperationalError as exc:
+ logger.warning("Task DB unavailable: %s", exc)
+ return HTMLResponse('Database unavailable
')
tasks = [_TaskView(_row_to_dict(r)) for r in rows]
parts = []
for task in tasks:
@@ -186,10 +198,14 @@ async def tasks_active(request: Request):
@router.get("/tasks/completed", response_class=HTMLResponse)
async def tasks_completed(request: Request):
"""Return HTMX partial for completed/vetoed/failed tasks (last 50)."""
- with _get_db() as db:
- rows = db.execute(
- "SELECT * FROM tasks WHERE status IN ('completed','vetoed','failed') ORDER BY completed_at DESC LIMIT 50"
- ).fetchall()
+ try:
+ with _get_db() as db:
+ rows = db.execute(
+ "SELECT * FROM tasks WHERE status IN ('completed','vetoed','failed') ORDER BY completed_at DESC LIMIT 50"
+ ).fetchall()
+ except sqlite3.OperationalError as exc:
+ logger.warning("Task DB unavailable: %s", exc)
+ return HTMLResponse('Database unavailable
')
tasks = [_TaskView(_row_to_dict(r)) for r in rows]
parts = []
for task in tasks:
@@ -225,13 +241,17 @@ async def create_task_form(
now = datetime.now(UTC).isoformat()
priority = priority if priority in VALID_PRIORITIES else "normal"
- with _get_db() as db:
- db.execute(
- "INSERT INTO tasks (id, title, description, priority, assigned_to, created_at) VALUES (?, ?, ?, ?, ?, ?)",
- (task_id, title, description, priority, assigned_to, now),
- )
- db.commit()
- row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
+ try:
+ with _get_db() as db:
+ db.execute(
+ "INSERT INTO tasks (id, title, description, priority, assigned_to, created_at) VALUES (?, ?, ?, ?, ?, ?)",
+ (task_id, title, description, priority, assigned_to, now),
+ )
+ db.commit()
+ row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
+ except sqlite3.OperationalError as exc:
+ logger.warning("Task DB unavailable: %s", exc)
+ raise HTTPException(status_code=503, detail="Task database unavailable") from exc
task = _TaskView(_row_to_dict(row))
return templates.TemplateResponse(request, "partials/task_card.html", {"task": task})
@@ -280,13 +300,17 @@ async def modify_task(
description: str = Form(""),
):
"""Update task title and description."""
- with _get_db() as db:
- db.execute(
- "UPDATE tasks SET title=?, description=? WHERE id=?",
- (title, description, task_id),
- )
- db.commit()
- row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
+ try:
+ with _get_db() as db:
+ db.execute(
+ "UPDATE tasks SET title=?, description=? WHERE id=?",
+ (title, description, task_id),
+ )
+ db.commit()
+ row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
+ except sqlite3.OperationalError as exc:
+ logger.warning("Task DB unavailable: %s", exc)
+ raise HTTPException(status_code=503, detail="Task database unavailable") from exc
if not row:
raise HTTPException(404, "Task not found")
task = _TaskView(_row_to_dict(row))
@@ -298,13 +322,17 @@ async def _set_status(request: Request, task_id: str, new_status: str):
completed_at = (
datetime.now(UTC).isoformat() if new_status in ("completed", "vetoed", "failed") else None
)
- with _get_db() as db:
- db.execute(
- "UPDATE tasks SET status=?, completed_at=COALESCE(?, completed_at) WHERE id=?",
- (new_status, completed_at, task_id),
- )
- db.commit()
- row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
+ try:
+ with _get_db() as db:
+ db.execute(
+ "UPDATE tasks SET status=?, completed_at=COALESCE(?, completed_at) WHERE id=?",
+ (new_status, completed_at, task_id),
+ )
+ db.commit()
+ row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
+ except sqlite3.OperationalError as exc:
+ logger.warning("Task DB unavailable: %s", exc)
+ raise HTTPException(status_code=503, detail="Task database unavailable") from exc
if not row:
raise HTTPException(404, "Task not found")
task = _TaskView(_row_to_dict(row))
@@ -330,22 +358,26 @@ async def api_create_task(request: Request):
if priority not in VALID_PRIORITIES:
priority = "normal"
- with _get_db() as db:
- db.execute(
- "INSERT INTO tasks (id, title, description, priority, assigned_to, created_by, created_at) "
- "VALUES (?, ?, ?, ?, ?, ?, ?)",
- (
- task_id,
- title,
- body.get("description", ""),
- priority,
- body.get("assigned_to", ""),
- body.get("created_by", "operator"),
- now,
- ),
- )
- db.commit()
- row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
+ try:
+ with _get_db() as db:
+ db.execute(
+ "INSERT INTO tasks (id, title, description, priority, assigned_to, created_by, created_at) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
+ (
+ task_id,
+ title,
+ body.get("description", ""),
+ priority,
+ body.get("assigned_to", ""),
+ body.get("created_by", "operator"),
+ now,
+ ),
+ )
+ db.commit()
+ row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
+ except sqlite3.OperationalError as exc:
+ logger.warning("Task DB unavailable: %s", exc)
+ raise HTTPException(status_code=503, detail="Task database unavailable") from exc
return JSONResponse(_row_to_dict(row), status_code=201)
@@ -353,8 +385,12 @@ async def api_create_task(request: Request):
@router.get("/api/tasks", response_class=JSONResponse)
async def api_list_tasks():
"""List all tasks as JSON."""
- with _get_db() as db:
- rows = db.execute("SELECT * FROM tasks ORDER BY created_at DESC").fetchall()
+ try:
+ with _get_db() as db:
+ rows = db.execute("SELECT * FROM tasks ORDER BY created_at DESC").fetchall()
+ except sqlite3.OperationalError as exc:
+ logger.warning("Task DB unavailable: %s", exc)
+ return JSONResponse([], status_code=200)
return JSONResponse([_row_to_dict(r) for r in rows])
@@ -369,13 +405,17 @@ async def api_update_status(task_id: str, request: Request):
completed_at = (
datetime.now(UTC).isoformat() if new_status in ("completed", "vetoed", "failed") else None
)
- with _get_db() as db:
- db.execute(
- "UPDATE tasks SET status=?, completed_at=COALESCE(?, completed_at) WHERE id=?",
- (new_status, completed_at, task_id),
- )
- db.commit()
- row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
+ try:
+ with _get_db() as db:
+ db.execute(
+ "UPDATE tasks SET status=?, completed_at=COALESCE(?, completed_at) WHERE id=?",
+ (new_status, completed_at, task_id),
+ )
+ db.commit()
+ row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
+ except sqlite3.OperationalError as exc:
+ logger.warning("Task DB unavailable: %s", exc)
+ raise HTTPException(status_code=503, detail="Task database unavailable") from exc
if not row:
raise HTTPException(404, "Task not found")
return JSONResponse(_row_to_dict(row))
@@ -384,9 +424,13 @@ async def api_update_status(task_id: str, request: Request):
@router.delete("/api/tasks/{task_id}", response_class=JSONResponse)
async def api_delete_task(task_id: str):
"""Delete a task."""
- with _get_db() as db:
- cursor = db.execute("DELETE FROM tasks WHERE id=?", (task_id,))
- db.commit()
+ try:
+ with _get_db() as db:
+ cursor = db.execute("DELETE FROM tasks WHERE id=?", (task_id,))
+ db.commit()
+ except sqlite3.OperationalError as exc:
+ logger.warning("Task DB unavailable: %s", exc)
+ raise HTTPException(status_code=503, detail="Task database unavailable") from exc
if cursor.rowcount == 0:
raise HTTPException(404, "Task not found")
return JSONResponse({"success": True, "id": task_id})
@@ -400,15 +444,19 @@ async def api_delete_task(task_id: str):
@router.get("/api/queue/status", response_class=JSONResponse)
async def queue_status(assigned_to: str = "default"):
"""Return queue status for the chat panel's agent status indicator."""
- with _get_db() as db:
- running = db.execute(
- "SELECT * FROM tasks WHERE status='running' AND assigned_to=? LIMIT 1",
- (assigned_to,),
- ).fetchone()
- ahead = db.execute(
- "SELECT COUNT(*) as cnt FROM tasks WHERE status IN ('pending_approval','approved') AND assigned_to=?",
- (assigned_to,),
- ).fetchone()
+ try:
+ with _get_db() as db:
+ running = db.execute(
+ "SELECT * FROM tasks WHERE status='running' AND assigned_to=? LIMIT 1",
+ (assigned_to,),
+ ).fetchone()
+ ahead = db.execute(
+ "SELECT COUNT(*) as cnt FROM tasks WHERE status IN ('pending_approval','approved') AND assigned_to=?",
+ (assigned_to,),
+ ).fetchone()
+ except sqlite3.OperationalError as exc:
+ logger.warning("Task DB unavailable: %s", exc)
+ return JSONResponse({"is_working": False, "current_task": None, "tasks_ahead": 0})
if running:
return JSONResponse(
diff --git a/tests/dashboard/test_tasks_api.py b/tests/dashboard/test_tasks_api.py
index 8afc5a6a..b94f8bfb 100644
--- a/tests/dashboard/test_tasks_api.py
+++ b/tests/dashboard/test_tasks_api.py
@@ -3,6 +3,99 @@
Verifies task CRUD operations and the dashboard page rendering.
"""
+import sqlite3
+from unittest.mock import patch
+
+
+# ---------------------------------------------------------------------------
+# DB error handling tests
+# ---------------------------------------------------------------------------
+
+_DB_ERROR = sqlite3.OperationalError("database is locked")
+
+
+def test_tasks_page_degrades_on_db_error(client):
+ """GET /tasks renders empty columns when DB is unavailable."""
+ with patch(
+ "dashboard.routes.tasks._get_db",
+ side_effect=_DB_ERROR,
+ ):
+ response = client.get("/tasks")
+ assert response.status_code == 200
+ assert "TASK QUEUE" in response.text
+
+
+def test_pending_partial_degrades_on_db_error(client):
+ """GET /tasks/pending returns fallback HTML when DB is unavailable."""
+ with patch(
+ "dashboard.routes.tasks._get_db",
+ side_effect=_DB_ERROR,
+ ):
+ response = client.get("/tasks/pending")
+ assert response.status_code == 200
+ assert "Database unavailable" in response.text
+
+
+def test_active_partial_degrades_on_db_error(client):
+ """GET /tasks/active returns fallback HTML when DB is unavailable."""
+ with patch(
+ "dashboard.routes.tasks._get_db",
+ side_effect=_DB_ERROR,
+ ):
+ response = client.get("/tasks/active")
+ assert response.status_code == 200
+ assert "Database unavailable" in response.text
+
+
+def test_completed_partial_degrades_on_db_error(client):
+ """GET /tasks/completed returns fallback HTML when DB is unavailable."""
+ with patch(
+ "dashboard.routes.tasks._get_db",
+ side_effect=_DB_ERROR,
+ ):
+ response = client.get("/tasks/completed")
+ assert response.status_code == 200
+ assert "Database unavailable" in response.text
+
+
+def test_api_create_task_503_on_db_error(client):
+ """POST /api/tasks returns 503 when DB is unavailable."""
+ with patch(
+ "dashboard.routes.tasks._get_db",
+ side_effect=_DB_ERROR,
+ ):
+ response = client.post("/api/tasks", json={"title": "Test"})
+ assert response.status_code == 503
+
+
+def test_api_list_tasks_empty_on_db_error(client):
+ """GET /api/tasks returns empty list when DB is unavailable."""
+ with patch(
+ "dashboard.routes.tasks._get_db",
+ side_effect=_DB_ERROR,
+ ):
+ response = client.get("/api/tasks")
+ assert response.status_code == 200
+ assert response.json() == []
+
+
+def test_queue_status_degrades_on_db_error(client):
+ """GET /api/queue/status returns idle status when DB is unavailable."""
+ with patch(
+ "dashboard.routes.tasks._get_db",
+ side_effect=_DB_ERROR,
+ ):
+ response = client.get("/api/queue/status")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["is_working"] is False
+ assert data["current_task"] is None
+
+
+# ---------------------------------------------------------------------------
+# Existing tests
+# ---------------------------------------------------------------------------
+
def test_tasks_page_returns_200(client):
response = client.get("/tasks")