feat: auto-route inbox tasks to best available agent (#476)

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.
This commit is contained in:
nyk 2026-03-22 19:06:07 +07:00 committed by GitHub
parent 27f6a3d6c1
commit 1acbf8e053
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 157 additions and 3 deletions

View File

@ -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()

View File

@ -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<string, string[]> = {
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`,
}
}