From 1acbf8e0537ca93c277ea603dc942e79f6d92c5b Mon Sep 17 00:00:00 2001 From: nyk <93952610+0xNyk@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:06:07 +0700 Subject: [PATCH] feat: auto-route inbox tasks to best available agent (#476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tasks in inbox status are now automatically assigned to the best matching agent based on role affinity and capability scoring: - Role keywords: coder matches code/implement/fix, researcher matches research/analyze/audit, etc. - Agent capabilities from config are matched against task text - Idle agents preferred over busy ones - Capacity limit: agents with 3+ in-progress tasks are skipped - Runs every 60s before task dispatch (inbox → assigned → dispatch) Flow: create task (inbox) → auto-route picks best agent (assigned) → scheduler dispatches to Claude API (in_progress) → response stored (review) → Aegis approves (done). Fully hands-free. --- src/lib/scheduler.ts | 10 ++- src/lib/task-dispatch.ts | 150 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 3 deletions(-) diff --git a/src/lib/scheduler.ts b/src/lib/scheduler.ts index e8b9090..9ff1814 100644 --- a/src/lib/scheduler.ts +++ b/src/lib/scheduler.ts @@ -10,7 +10,7 @@ import { pruneGatewaySessionsOlderThan, getAgentLiveStatuses } from './sessions' import { eventBus } from './event-bus' import { syncSkillsFromDisk } from './skill-sync' import { syncLocalAgents } from './local-agent-sync' -import { dispatchAssignedTasks, runAegisReviews, requeueStaleTasks } from './task-dispatch' +import { dispatchAssignedTasks, runAegisReviews, requeueStaleTasks, autoRouteInboxTasks } from './task-dispatch' import { spawnRecurringTasks } from './recurring-tasks' const BACKUP_DIR = join(dirname(config.dbPath), 'backups') @@ -449,7 +449,11 @@ async function tick() { const refreshed = await syncAgentLiveStatuses() return { ok: true, message: `Gateway sync: ${r.created} created, ${r.updated} updated, ${r.synced} total | Live status: ${refreshed} refreshed` } }) - : id === 'task_dispatch' ? await dispatchAssignedTasks() + : id === 'task_dispatch' ? await autoRouteInboxTasks().then(async (routeResult) => { + const dispatchResult = await dispatchAssignedTasks() + const parts = [routeResult.message, dispatchResult.message].filter(m => m && !m.includes('No ')) + return { ok: routeResult.ok && dispatchResult.ok, message: parts.join(' | ') || 'No tasks to route or dispatch' } + }) : id === 'aegis_review' ? await runAegisReviews() : id === 'recurring_task_spawn' ? await spawnRecurringTasks() : id === 'stale_task_requeue' ? await requeueStaleTasks() @@ -515,7 +519,7 @@ export async function triggerTask(taskId: string): Promise<{ ok: boolean; messag if (taskId === 'skill_sync') return syncSkillsFromDisk() if (taskId === 'local_agent_sync') return syncLocalAgents() if (taskId === 'gateway_agent_sync') return syncAgentsFromConfig('manual').then(r => ({ ok: true, message: `Gateway sync: ${r.created} created, ${r.updated} updated, ${r.synced} total` })) - if (taskId === 'task_dispatch') return dispatchAssignedTasks() + if (taskId === 'task_dispatch') return autoRouteInboxTasks().then(async (r) => { const d = await dispatchAssignedTasks(); return { ok: r.ok && d.ok, message: [r.message, d.message].filter(m => m && !m.includes('No ')).join(' | ') || 'No tasks' } }) if (taskId === 'aegis_review') return runAegisReviews() if (taskId === 'recurring_task_spawn') return spawnRecurringTasks() if (taskId === 'stale_task_requeue') return requeueStaleTasks() diff --git a/src/lib/task-dispatch.ts b/src/lib/task-dispatch.ts index 2077b06..6e4e4aa 100644 --- a/src/lib/task-dispatch.ts +++ b/src/lib/task-dispatch.ts @@ -877,3 +877,153 @@ export async function dispatchAssignedTasks(): Promise<{ ok: boolean; message: s message: `Dispatched ${succeeded}/${tasks.length} tasks${failSummary}`, } } + +// --------------------------------------------------------------------------- +// Auto-routing: assign inbox tasks to available agents +// --------------------------------------------------------------------------- + +/** Role affinity mapping — which task keywords match which agent roles. */ +const ROLE_AFFINITY: Record = { + coder: ['code', 'implement', 'build', 'fix', 'bug', 'test', 'unit test', 'refactor', 'feature', 'api', 'endpoint', 'function', 'class', 'module', 'component', 'deploy', 'ci', 'pipeline'], + researcher: ['research', 'investigate', 'analyze', 'compare', 'find', 'discover', 'audit', 'review', 'survey', 'benchmark', 'evaluate', 'assess', 'competitor', 'market', 'trend'], + reviewer: ['review', 'audit', 'check', 'verify', 'validate', 'quality', 'security', 'compliance', 'approve'], + tester: ['test', 'qa', 'e2e', 'integration test', 'regression', 'coverage', 'verify', 'validate'], + devops: ['deploy', 'infrastructure', 'ci', 'cd', 'docker', 'kubernetes', 'monitoring', 'pipeline', 'server', 'nginx', 'ssl'], + assistant: ['write', 'draft', 'summarize', 'translate', 'format', 'document', 'docs', 'readme', 'email', 'message', 'report'], + agent: [], // generic fallback +} + +function scoreAgentForTask( + agent: { name: string; role: string; status: string; config: string | null }, + taskText: string, +): number { + // Offline agents can't take work + if (agent.status === 'offline' || agent.status === 'error' || agent.status === 'sleeping') return -1 + + const text = taskText.toLowerCase() + const keywords = ROLE_AFFINITY[agent.role] || [] + + let score = 0 + // Role keyword match + for (const kw of keywords) { + if (text.includes(kw)) score += 10 + } + + // Idle agents get a bonus (prefer agents not currently busy) + if (agent.status === 'idle') score += 5 + + // Check agent capabilities from config + if (agent.config) { + try { + const cfg = JSON.parse(agent.config) + const caps = Array.isArray(cfg.capabilities) ? cfg.capabilities : [] + for (const cap of caps) { + if (typeof cap === 'string' && text.includes(cap.toLowerCase())) score += 15 + } + } catch { /* ignore */ } + } + + // Any non-offline agent gets at least 1 (can be a fallback) + return Math.max(score, 1) +} + +/** + * Auto-route inbox tasks to the best available agent. + * Runs before dispatch — moves tasks from inbox → assigned. + */ +export async function autoRouteInboxTasks(): Promise<{ ok: boolean; message: string }> { + const db = getDatabase() + + const inboxTasks = db.prepare(` + SELECT id, title, description, priority, tags, workspace_id + FROM tasks + WHERE status = 'inbox' AND assigned_to IS NULL + ORDER BY + CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END ASC, + created_at ASC + LIMIT 5 + `).all() as Array<{ id: number; title: string; description: string | null; priority: string; tags: string | null; workspace_id: number }> + + if (inboxTasks.length === 0) { + return { ok: true, message: 'No inbox tasks to route' } + } + + // Get all non-hidden, non-offline agents + const agents = db.prepare(` + SELECT id, name, role, status, config + FROM agents + WHERE hidden = 0 AND status NOT IN ('offline', 'error') + LIMIT 50 + `).all() as Array<{ id: number; name: string; role: string; status: string; config: string | null }> + + if (agents.length === 0) { + return { ok: true, message: `${inboxTasks.length} inbox task(s) but no available agents` } + } + + let routed = 0 + const now = Math.floor(Date.now() / 1000) + + for (const task of inboxTasks) { + const taskText = `${task.title} ${task.description || ''}` + let parsedTags: string[] = [] + if (task.tags) { + try { parsedTags = JSON.parse(task.tags) } catch { /* ignore */ } + } + const fullText = `${taskText} ${parsedTags.join(' ')}` + + // Score each agent + const scored = agents + .map(a => ({ agent: a, score: scoreAgentForTask(a, fullText) })) + .filter(s => s.score > 0) + .sort((a, b) => b.score - a.score) + + if (scored.length === 0) continue + + const best = scored[0].agent + + // Check capacity — skip agents with 3+ in-progress tasks + const inProgressCount = (db.prepare( + 'SELECT COUNT(*) as c FROM tasks WHERE assigned_to = ? AND status = \'in_progress\' AND workspace_id = ?' + ).get(best.name, task.workspace_id) as { c: number }).c + + if (inProgressCount >= 3) { + // Try next best agent + const alt = scored.find(s => { + const c = (db.prepare( + 'SELECT COUNT(*) as c FROM tasks WHERE assigned_to = ? AND status = \'in_progress\' AND workspace_id = ?' + ).get(s.agent.name, task.workspace_id) as { c: number }).c + return c < 3 + }) + if (!alt) continue // all agents at capacity + db.prepare('UPDATE tasks SET status = ?, assigned_to = ?, updated_at = ? WHERE id = ?') + .run('assigned', alt.agent.name, now, task.id) + + db_helpers.logActivity('task_auto_routed', 'task', task.id, 'scheduler', + `Auto-assigned "${task.title}" to ${alt.agent.name} (${alt.agent.role}, score: ${alt.score})`, + { agent: alt.agent.name, role: alt.agent.role, score: alt.score }, + task.workspace_id) + + eventBus.broadcast('task.status_changed', { id: task.id, status: 'assigned', previous_status: 'inbox', assigned_to: alt.agent.name }) + routed++ + continue + } + + db.prepare('UPDATE tasks SET status = ?, assigned_to = ?, updated_at = ? WHERE id = ?') + .run('assigned', best.name, now, task.id) + + db_helpers.logActivity('task_auto_routed', 'task', task.id, 'scheduler', + `Auto-assigned "${task.title}" to ${best.name} (${best.role}, score: ${scored[0].score})`, + { agent: best.name, role: best.role, score: scored[0].score }, + task.workspace_id) + + eventBus.broadcast('task.status_changed', { id: task.id, status: 'assigned', previous_status: 'inbox', assigned_to: best.name }) + routed++ + } + + return { + ok: true, + message: routed > 0 + ? `Auto-routed ${routed}/${inboxTasks.length} inbox task(s)` + : `${inboxTasks.length} inbox task(s), no suitable agents found`, + } +}