feat: background task dispatch system with live UI panel

- agent/task_registry.py: file-based JSONL event registry (cross-process)
- agent/task_worker.py: autonomous LLM loop with weather/time/memory/web tools
- agent/dispatch_mcp.py: MCP tool exposing dispatch_task to the main agent
- agent/agent.py: registers dispatch toolset, polls task events → room data
- web: slide-out task panel (FAB button + badge), live step streaming via
  data channel topic 'tasks', status dots (running/completed/failed)
- Dockerfile: copies new task_*.py and dispatch_mcp.py files

The dispatch MCP runs in its own process; events flow through
/tmp/tasks/events.jsonl which the main agent tails every second and
forwards to the browser. Tasks run up to 10 LLM iterations with tool calls.
This commit is contained in:
Shane
2026-08-22 18:06:23 -04:00
parent d372adca7d
commit 44e8d05e2c
8 changed files with 457 additions and 48 deletions
+75 -37
View File
@@ -1,18 +1,29 @@
"""In-memory task registry for background dispatch tasks.
"""File-based task registry for background dispatch tasks.
One global instance per agent process. Workers add steps as they progress;
the agent subscribes to push updates to the room (UI) and to speak results.
The dispatch MCP server runs in a separate process, so we use a JSONL event
file as the communication channel. The main agent process tails this file
and pushes events to the room (UI) and speaks results.
Event file: /tmp/tasks/events.jsonl
Task state: /tmp/tasks/{task_id}.json
"""
from __future__ import annotations
import asyncio
import json
import os
import time
import uuid
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any, Callable
TASKS_DIR = Path(os.environ.get("TASKS_DIR", "/tmp/tasks"))
EVENTS_FILE = TASKS_DIR / "events.jsonl"
class TaskStatus(str, Enum):
PENDING = "pending"
RUNNING = "running"
@@ -22,7 +33,7 @@ class TaskStatus(str, Enum):
@dataclass
class TaskStep:
role: str # "thinking", "tool_call", "tool_result", "text"
role: str
content: str
timestamp: float = field(default_factory=time.time)
@@ -40,63 +51,81 @@ class Task:
class TaskRegistry:
"""Thread-safe registry of background tasks with pub/sub notifications."""
"""File-backed registry. Writers (MCP process) append to events.jsonl.
Readers (main agent) tail the file and dispatch to subscribers."""
def __init__(self):
TASKS_DIR.mkdir(parents=True, exist_ok=True)
if not EVENTS_FILE.exists():
EVENTS_FILE.touch()
self._tasks: dict[str, Task] = {}
self._listeners: list[Callable[[str, dict], Any]] = []
self._lock = asyncio.Lock()
self._tail_pos = 0
async def create(self, description: str) -> Task:
def create(self, description: str) -> Task:
task = Task(id=uuid.uuid4().hex[:8], description=description)
async with self._lock:
self._tasks[task.id] = task
await self._notify(task.id, {"event": "created", "task": self._task_dict(task)})
self._tasks[task.id] = task
self._write_event(task.id, {"event": "created", "task": self._task_dict(task)})
return task
async def add_step(self, task_id: str, role: str, content: str):
async with self._lock:
task = self._tasks.get(task_id)
if not task:
return
def add_step(self, task_id: str, role: str, content: str):
task = self._tasks.get(task_id)
if task:
task.steps.append(TaskStep(role=role, content=content))
if task.status == TaskStatus.PENDING:
task.status = TaskStatus.RUNNING
await self._notify(task_id, {"event": "step", "role": role, "content": content})
self._save_task(task)
self._write_event(task_id, {"event": "step", "role": role, "content": content})
async def complete(self, task_id: str, result: str):
async with self._lock:
task = self._tasks.get(task_id)
if not task:
return
def complete(self, task_id: str, result: str):
task = self._tasks.get(task_id)
if task:
task.status = TaskStatus.COMPLETED
task.result = result
task.completed_at = time.time()
await self._notify(task_id, {"event": "completed", "result": result})
self._save_task(task)
self._write_event(task_id, {"event": "completed", "result": result})
async def fail(self, task_id: str, error: str):
async with self._lock:
task = self._tasks.get(task_id)
if not task:
return
def fail(self, task_id: str, error: str):
task = self._tasks.get(task_id)
if task:
task.status = TaskStatus.FAILED
task.error = error
task.completed_at = time.time()
await self._notify(task_id, {"event": "failed", "error": error})
self._save_task(task)
self._write_event(task_id, {"event": "failed", "error": error})
async def get(self, task_id: str) -> dict | None:
async with self._lock:
t = self._tasks.get(task_id)
return self._task_dict(t) if t else None
def get(self, task_id: str) -> dict | None:
t = self._tasks.get(task_id)
return self._task_dict(t) if t else None
async def list_all(self) -> list[dict]:
async with self._lock:
return [self._task_dict(t) for t in self._tasks.values()]
def list_all(self) -> list[dict]:
return [self._task_dict(t) for t in self._tasks.values()]
def subscribe(self, callback: Callable[[str, dict], Any]):
self._listeners.append(callback)
async def _notify(self, task_id: str, event: dict):
async def poll_events(self) -> list[tuple[str, dict]]:
"""Read new events from the file. Call periodically from the agent."""
events = []
try:
with open(EVENTS_FILE, "r") as f:
f.seek(self._tail_pos)
for line in f:
line = line.strip()
if not line:
continue
try:
ev = json.loads(line)
events.append((ev.get("task_id", ""), ev))
except json.JSONDecodeError:
pass
self._tail_pos = f.tell()
except (OSError, IOError):
pass
return events
async def notify_subscribers(self, task_id: str, event: dict):
for cb in self._listeners:
try:
result = cb(task_id, event)
@@ -105,6 +134,15 @@ class TaskRegistry:
except Exception:
pass
def _write_event(self, task_id: str, event: dict):
payload = json.dumps({"task_id": task_id, **event})
with open(EVENTS_FILE, "a") as f:
f.write(payload + "\n")
def _save_task(self, task: Task):
path = TASKS_DIR / f"{task.id}.json"
path.write_text(json.dumps(self._task_dict(task)))
@staticmethod
def _task_dict(task: Task) -> dict:
return {
@@ -122,5 +160,5 @@ class TaskRegistry:
}
# Global singleton — one per agent process
# Global singleton — one per process
registry = TaskRegistry()