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:
parent
fec0601722
commit
b11da78097
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
75
server.py
75
server.py
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in New Issue