Rebuild & Deploy / rebuild (push) Successful in 58s
Background research tasks were producing garbage or failing because the self-hosted Firecrawl /v1/scrape defaults to a ~30s timeout that's too short for heavy news/research sites, and the small LLM won't reliably chain a second scrape call after a search that only returns snippets. task_worker.py: - web_search now auto-fetches the top results directly from the container (which has working outbound internet) and returns their real text, so the model gets actual page content in one call instead of snippet echoes. - web_scrape requests timeout: 90000 (Firecrawl renders heavy sites but needs >30s), and falls back to a direct container fetch with tag-stripping if Firecrawl times out or returns nothing. task_registry.py / dispatch_mcp.py: - Add task_status(id) and list_tasks() tools so Hope can check the live state of any dispatched task (pending / running / completed / failed / STALLED). - Track last_activity per task and surface a stall heuristic. - Move the status summary into task_registry (no mcp dependency) so it's unit-testable. agent.py: prompt tells Hope about task_status / list_tasks. tests: add task-status summary and last-activity regression tests.
202 lines
6.8 KiB
Python
202 lines
6.8 KiB
Python
"""File-based task registry for background dispatch tasks.
|
|
|
|
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"
|
|
COMPLETED = "completed"
|
|
FAILED = "failed"
|
|
|
|
|
|
@dataclass
|
|
class TaskStep:
|
|
role: str
|
|
content: str
|
|
timestamp: float = field(default_factory=time.time)
|
|
|
|
|
|
@dataclass
|
|
class Task:
|
|
id: str
|
|
description: str
|
|
status: TaskStatus = TaskStatus.PENDING
|
|
steps: list[TaskStep] = field(default_factory=list)
|
|
result: str | None = None
|
|
error: str | None = None
|
|
created_at: float = field(default_factory=time.time)
|
|
completed_at: float | None = None
|
|
last_activity: float = field(default_factory=time.time)
|
|
|
|
|
|
class TaskRegistry:
|
|
"""File-backed registry. Writers (MCP process) append to events.jsonl.
|
|
Readers (main agent) tail the file and dispatch to subscribers.
|
|
|
|
A registry can be scoped to a task directory. Voice sessions use a unique
|
|
directory so one room can never consume another room's task results.
|
|
"""
|
|
|
|
def __init__(self, tasks_dir: str | Path | None = None):
|
|
self.tasks_dir = Path(tasks_dir) if tasks_dir is not None else TASKS_DIR
|
|
self.events_file = self.tasks_dir / "events.jsonl"
|
|
self.tasks_dir.mkdir(parents=True, exist_ok=True)
|
|
if not self.events_file.exists():
|
|
self.events_file.touch()
|
|
self._tasks: dict[str, Task] = {}
|
|
self._listeners: list[Callable[[str, dict], Any]] = []
|
|
self._tail_pos = 0
|
|
|
|
def create(self, description: str) -> Task:
|
|
task = Task(id=uuid.uuid4().hex[:8], description=description)
|
|
self._tasks[task.id] = task
|
|
self._write_event(task.id, {"event": "created", "task": self._task_dict(task)})
|
|
return task
|
|
|
|
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))
|
|
task.last_activity = time.time()
|
|
if task.status == TaskStatus.PENDING:
|
|
task.status = TaskStatus.RUNNING
|
|
self._save_task(task)
|
|
self._write_event(task_id, {"event": "step", "role": role, "content": content})
|
|
|
|
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()
|
|
self._save_task(task)
|
|
self._write_event(task_id, {"event": "completed", "result": result})
|
|
|
|
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()
|
|
self._save_task(task)
|
|
self._write_event(task_id, {"event": "failed", "error": error})
|
|
|
|
def get(self, task_id: str) -> dict | None:
|
|
t = self._tasks.get(task_id)
|
|
return self._task_dict(t) if t else None
|
|
|
|
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 poll_events(self) -> list[tuple[str, dict]]:
|
|
"""Read new events from the file. Call periodically from the agent."""
|
|
events = []
|
|
try:
|
|
with open(self.events_file, "r", encoding="utf-8") 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)
|
|
if asyncio.iscoroutine(result):
|
|
await result
|
|
except Exception:
|
|
pass
|
|
|
|
def _write_event(self, task_id: str, event: dict):
|
|
payload = json.dumps({"task_id": task_id, **event})
|
|
with open(self.events_file, "a", encoding="utf-8") as f:
|
|
f.write(payload + "\n")
|
|
|
|
def _save_task(self, task: Task):
|
|
path = self.tasks_dir / f"{task.id}.json"
|
|
path.write_text(json.dumps(self._task_dict(task)), encoding="utf-8")
|
|
|
|
@staticmethod
|
|
def _task_dict(task: Task) -> dict:
|
|
return {
|
|
"id": task.id,
|
|
"description": task.description,
|
|
"status": task.status.value,
|
|
"steps": [
|
|
{"role": s.role, "content": s.content, "ts": s.timestamp}
|
|
for s in task.steps
|
|
],
|
|
"result": task.result,
|
|
"error": task.error,
|
|
"created_at": task.created_at,
|
|
"completed_at": task.completed_at,
|
|
"last_activity": task.last_activity,
|
|
}
|
|
|
|
|
|
# Global singleton — one per process
|
|
registry = TaskRegistry()
|
|
|
|
|
|
def status_summary(task_id: str, stalled_s: float = 45.0) -> str:
|
|
"""Human-readable live status for one task, plus a stall hint.
|
|
|
|
Kept on the registry module (no MCP dependency) so it can be reused and
|
|
unit-tested; the dispatch MCP's task_status/list_tasks tools call this.
|
|
"""
|
|
t = registry.get(task_id)
|
|
if not t:
|
|
return f"Task {task_id} was not found."
|
|
status = t.get("status", "unknown")
|
|
steps = t.get("steps") or []
|
|
created = t.get("created_at") or 0
|
|
last = t.get("last_activity") or created
|
|
elapsed = time.time() - created
|
|
idle = time.time() - last
|
|
parts = [f"Task {task_id} ({t.get('description', '')[:60]}): {status.upper()}"]
|
|
parts.append(f"elapsed {elapsed:,.0f}s, {len(steps)} step(s)")
|
|
if idle > 0.5:
|
|
parts.append(f"last activity {idle:,.0f}s ago")
|
|
if status in ("pending", "running") and idle > stalled_s:
|
|
parts.append(f"STALLED (no activity for {idle:,.0f}s)")
|
|
if t.get("error"):
|
|
parts.append(f"error: {t['error']}")
|
|
if t.get("result"):
|
|
parts.append(f"result: {t['result'][:240]}")
|
|
return ". ".join(parts)
|