Wire up autonomous agent dispatch for Kanban tasks

Assigning a task to opencode, hermes, or gemini now actually runs it:
creating or PATCHing a task with one of those assignees moves it to
in_progress and hands the title+body to execute_agent() in a
background thread. On completion the agent's response is appended as
a task comment and the task is marked done (success) or blocked
(timeout/error), matching the existing block/complete state machine.

Adds POST /api/kanban/tasks/{id}/dispatch for manual dispatch/retry,
and makes the previously-stubbed POST /api/kanban/dispatch actually
scan todo/ready tasks with an agent assignee and dispatch each one.

Dashboard: the assignee field is now a select of the three agents,
the task detail modal shows an activity log of past agent runs and a
Dispatch button, and polls every 3s while a task is in_progress. Also
fixed a pre-existing bug where the detail modal read a nonexistent
kanbanData.tasks field (the board API only returns columns), so
clicking a card silently did nothing before this fix.
This commit is contained in:
Claude 2026-07-05 23:16:46 +00:00
parent fec0601722
commit b11da78097
3 changed files with 141 additions and 11 deletions

View File

@ -67,6 +67,7 @@ const api = {
linkKanbanTasks: (parentId, childId) => api.post('/api/kanban/links', { parent_id: parentId, child_id: childId }),
unlinkKanbanTasks: (parentId, childId) => api.del(`/api/kanban/links?parent_id=${encodeURIComponent(parentId)}&child_id=${encodeURIComponent(childId)}`),
dispatchKanban: () => api.post('/api/kanban/dispatch', {}),
dispatchKanbanTask: (id) => api.post(`/api/kanban/tasks/${encodeURIComponent(id)}/dispatch`, {}),
specifyKanbanTask: (id) => api.post(`/api/kanban/tasks/${encodeURIComponent(id)}/specify`, {}),
decomposeKanbanTask: (id) => api.post(`/api/kanban/tasks/${encodeURIComponent(id)}/decompose`, {}),
// Goals

View File

@ -1,5 +1,9 @@
let kanbanData = null;
function getAllKanbanTasks() {
return Object.values(kanbanData?.columns || {}).flat();
}
async function renderKanban() {
const content = document.getElementById('pageContent');
content.innerHTML = `
@ -53,7 +57,7 @@ function renderKanbanBoard() {
if (!board || !kanbanData) return;
const columnsObj = kanbanData.columns || {};
const columns = Object.keys(columnsObj);
const allTasks = kanbanData.tasks || Object.values(columnsObj).flat();
const allTasks = getAllKanbanTasks();
const filterText = (document.getElementById('kanbanFilterInput')?.value || '').toLowerCase();
const filterPriority = document.getElementById('kanbanFilterPriority')?.value || 'all';
const filterCategory = document.getElementById('kanbanFilterCategory')?.value || 'all';
@ -147,7 +151,12 @@ function showAddKanbanTask() {
<div class="form-row">
<div class="form-group">
<label class="form-label">Assigned To</label>
<input class="form-input" id="kanbanAssigned" placeholder="e.g., opencode" style="text-transform:lowercase">
<select class="form-select" id="kanbanAssigned">
<option value="">Unassigned</option>
<option value="opencode">opencode (auto-run)</option>
<option value="hermes">Hermes (auto-run)</option>
<option value="gemini">Gemini CLI (auto-run)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Target Date</label>
@ -206,31 +215,68 @@ async function onKanbanDrop(e, status) {
}
}
function showKanbanDetail(id) {
const task = kanbanData?.tasks?.find(t => t.id === id);
const KANBAN_AGENTS = ['opencode', 'hermes', 'gemini'];
function closeKanbanDetailModal() {
if (window._kanbanDetailPoll) { clearInterval(window._kanbanDetailPoll); window._kanbanDetailPoll = null; }
closeModal();
}
async function showKanbanDetail(id) {
const task = getAllKanbanTasks().find(t => t.id === id);
if (!task) return;
renderKanbanDetailModal(task);
if (window._kanbanDetailPoll) clearInterval(window._kanbanDetailPoll);
if (task.status === 'in_progress') {
window._kanbanDetailPoll = setInterval(async () => {
try {
const fresh = await api.getKanbanTask(id);
if (fresh.status !== 'in_progress') {
clearInterval(window._kanbanDetailPoll);
window._kanbanDetailPoll = null;
renderKanban();
}
if (document.getElementById('kanbanDetailModal')) renderKanbanDetailModal(fresh);
} catch {}
}, 3000);
}
}
function renderKanbanDetailModal(task) {
const modal = document.getElementById('modalContainer');
const canDispatch = KANBAN_AGENTS.includes(task.assignee) && task.status !== 'in_progress' && task.status !== 'done';
const comments = task.comments || [];
modal.innerHTML = `
<div class="modal-overlay" onclick="if(event.target===this)closeModal()">
<div class="modal" style="max-width:560px">
<div class="modal-overlay" onclick="if(event.target===this)closeKanbanDetailModal()">
<div class="modal" id="kanbanDetailModal" style="max-width:560px">
<div class="modal-header">
<div class="modal-title">${escapeHtml(task.title)}</div>
<button class="modal-close" onclick="closeModal()"></button>
<button class="modal-close" onclick="closeKanbanDetailModal()"></button>
</div>
<div class="modal-body">
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px">
<span class="badge badge-${task.status === 'done' ? 'success' : task.status === 'in_progress' ? 'info' : 'warning'}">${task.status}</span>
<span class="kanban-priority priority-${task.priority || 'medium'}">${task.priority}</span>
${task.status === 'blocked' ? `<span class="badge badge-danger">🚫 Blocked</span>` : ''}
${task.status === 'in_progress' ? `<span class="badge badge-info">🔄 Agent running…</span>` : ''}
</div>
${task.body ? `<div style="margin-bottom:12px;color:var(--text-secondary);font-size:13px">${escapeHtml(task.body)}</div>` : ''}
<div style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:12px;font-size:12px">
${task.assignee ? `<span>👤 <strong>${escapeHtml(task.assignee)}</strong></span>` : ''}
${task.assignee ? `<span>👤 <strong>${escapeHtml(task.assignee)}</strong>${KANBAN_AGENTS.includes(task.assignee) ? ' 🤖' : ''}</span>` : ''}
<span>📅 <strong>${task.created || 'N/A'}</strong></span>
${task.completed_at ? `<span>✅ <strong>${task.completed_at}</strong></span>` : ''}
</div>
${task.block_reason ? `<div style="margin-bottom:12px;font-size:12px;color:var(--red)">🚫 ${escapeHtml(task.block_reason)}</div>` : ''}
${comments.length ? `
<div style="margin-bottom:12px">
<div style="font-size:11px;font-weight:700;text-transform:uppercase;color:var(--text-muted);margin-bottom:6px">Activity</div>
${comments.map(c => `<div style="padding:8px 10px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius-sm);font-size:12px;white-space:pre-wrap;margin-bottom:6px">${escapeHtml(c.message)}</div>`).join('')}
</div>
` : ''}
<div style="display:flex;gap:4px;flex-wrap:wrap">
${task.status !== 'done' ? `<button class="btn btn-sm btn-primary" onclick="completeKanbanTask('${task.id}')">✅ Mark Done</button>` : ''}
${canDispatch ? `<button class="btn btn-sm btn-primary" onclick="dispatchKanbanTask('${task.id}')">🤖 Dispatch to ${escapeHtml(task.assignee)}</button>` : ''}
${task.status !== 'done' ? `<button class="btn btn-sm btn-ghost" onclick="completeKanbanTask('${task.id}')">✅ Mark Done</button>` : ''}
${task.status !== 'blocked' ? `<button class="btn btn-sm btn-ghost" onclick="blockKanbanTask('${task.id}')">🚫 Block</button>` : `<button class="btn btn-sm btn-ghost" onclick="unblockKanbanTask('${task.id}')">🔓 Unblock</button>`}
<button class="btn btn-sm btn-ghost" onclick="deleteKanbanTask('${task.id}')" style="color:var(--red)">🗑 Delete</button>
</div>
@ -240,6 +286,18 @@ function showKanbanDetail(id) {
`;
}
async function dispatchKanbanTask(id) {
try {
await api.dispatchKanbanTask(id);
showToast('Task dispatched to agent 🤖', 'success');
await loadKanbanData();
const fresh = getAllKanbanTasks().find(t => t.id === id);
if (fresh) showKanbanDetail(id);
} catch (err) {
showToast('Failed to dispatch: ' + err.message, 'error');
}
}
async function completeKanbanTask(id) {
try {
await api.completeKanbanTask(id);

View File

@ -9,6 +9,7 @@ import os
import shutil
import subprocess
import tarfile
import threading
import time
import uuid
from datetime import datetime, timezone
@ -776,6 +777,53 @@ def save_kanban_task(task: dict):
ensure_dir(KANBAN_DIR)
(KANBAN_DIR / f"{task['id']}.json").write_text(json.dumps(task, indent=2))
KANBAN_AGENTS = {"opencode", "hermes", "gemini"}
def dispatch_kanban_task(task_id: str):
"""Move a task to in_progress and hand it to its assignee agent in the background."""
path = KANBAN_DIR / f"{task_id}.json"
if not path.exists():
return
task = json.loads(path.read_text())
if task.get("status") in ("in_progress", "done"):
return
if task.get("assignee") not in KANBAN_AGENTS:
return
task["status"] = "in_progress"
task["updated"] = get_timestamp()
save_kanban_task(task)
append_audit({"action": "kanban_task_dispatched", "task_id": task_id, "agent": task["assignee"]})
threading.Thread(target=_run_kanban_agent, args=(task_id,), daemon=True).start()
def _run_kanban_agent(task_id: str):
path = KANBAN_DIR / f"{task_id}.json"
if not path.exists():
return
task = json.loads(path.read_text())
agent = task.get("assignee")
prompt = task["title"] if not task.get("body") else f"{task['title']}\n\n{task['body']}"
response = execute_agent(agent, prompt)
failed = response.startswith(("", "", "Unknown agent"))
task = json.loads(path.read_text()) # reload in case it changed while the agent ran
task.setdefault("comments", []).append({
"id": str(uuid.uuid4())[:8],
"message": f"🤖 **{agent}**\n\n{response}",
"timestamp": get_timestamp(),
})
if failed:
task["status"] = "blocked"
task["block_reason"] = response[:300]
append_audit({"action": "kanban_task_dispatch_failed", "task_id": task_id, "agent": agent})
else:
task["status"] = "done"
task["summary"] = response[:300]
task["completed_at"] = get_timestamp()
append_audit({"action": "kanban_task_dispatch_completed", "task_id": task_id, "agent": agent})
task["updated"] = get_timestamp()
save_kanban_task(task)
def load_goals():
if GOALS_FILE.exists():
return json.loads(GOALS_FILE.read_text())
@ -825,6 +873,9 @@ def kanban_create_task(data: KanbanTaskCreate):
}
save_kanban_task(task)
append_audit({"action": "kanban_task_created", "title": data.title})
if task["assignee"] in KANBAN_AGENTS:
dispatch_kanban_task(task["id"])
task = json.loads((KANBAN_DIR / f"{task['id']}.json").read_text())
return task
except Exception as e:
raise HTTPException(500, str(e))
@ -835,6 +886,7 @@ def kanban_update_task(task_id: str, data: KanbanTaskUpdate):
if not path.exists():
raise HTTPException(404, "Task not found")
task = json.loads(path.read_text())
assignee_changed = data.assignee is not None and data.assignee != task.get("assignee")
for field in ["title", "body", "status", "priority", "assignee"]:
val = getattr(data, field, None)
if val is not None:
@ -842,8 +894,22 @@ def kanban_update_task(task_id: str, data: KanbanTaskUpdate):
task["updated"] = get_timestamp()
save_kanban_task(task)
append_audit({"action": "kanban_task_updated", "task_id": task_id})
if assignee_changed and task["assignee"] in KANBAN_AGENTS:
dispatch_kanban_task(task_id)
task = json.loads(path.read_text())
return task
@app.post("/api/kanban/tasks/{task_id}/dispatch")
def kanban_dispatch_task(task_id: str):
path = KANBAN_DIR / f"{task_id}.json"
if not path.exists():
raise HTTPException(404, "Task not found")
task = json.loads(path.read_text())
if task.get("assignee") not in KANBAN_AGENTS:
raise HTTPException(400, "Task must be assigned to opencode, hermes, or gemini to dispatch")
dispatch_kanban_task(task_id)
return {"status": "dispatched", "task_id": task_id}
@app.post("/api/kanban/tasks/{task_id}/complete")
def kanban_complete_task(task_id: str, data: KanbanComplete):
path = KANBAN_DIR / f"{task_id}.json"
@ -930,8 +996,13 @@ def kanban_remove_link(parent_id: str = Query(...), child_id: str = Query(...)):
@app.post("/api/kanban/dispatch")
def kanban_dispatch():
append_audit({"action": "kanban_dispatch_triggered"})
return {"status": "dispatch_triggered", "message": "Dispatcher notified"}
dispatched = []
for task in load_kanban_tasks():
if task.get("status") in ("todo", "ready") and task.get("assignee") in KANBAN_AGENTS:
dispatch_kanban_task(task["id"])
dispatched.append(task["id"])
append_audit({"action": "kanban_dispatch_triggered", "task_ids": dispatched})
return {"status": "dispatch_triggered", "dispatched": dispatched, "message": f"Dispatched {len(dispatched)} task(s)"}
@app.post("/api/kanban/tasks/{task_id}/specify")
def kanban_specify_task(task_id: str):