diff --git a/messages/ar.json b/messages/ar.json index aa4a2814c..8c8a527a6 100644 --- a/messages/ar.json +++ b/messages/ar.json @@ -608,6 +608,8 @@ "colInProgress": "قيد التنفيذ", "colReview": "المراجعة", "colQualityReview": "مراجعة الجودة", + "colFailed": "فشل", + "retryTask": "إعادة المحاولة", "colDone": "مكتمل", "recurring": "متكرر", "spawned": "مُنشأ", diff --git a/messages/de.json b/messages/de.json index 5a5eda5ef..3c884e99d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -608,6 +608,8 @@ "colInProgress": "In Bearbeitung", "colReview": "Überprüfung", "colQualityReview": "Qualitätsprüfung", + "colFailed": "Fehlgeschlagen", + "retryTask": "Erneut versuchen", "colDone": "Erledigt", "recurring": "WIEDERKEHREND", "spawned": "ERSTELLT", diff --git a/messages/en.json b/messages/en.json index c55dfeeda..b906d6161 100644 --- a/messages/en.json +++ b/messages/en.json @@ -757,6 +757,8 @@ "colInProgress": "In Progress", "colReview": "Review", "colQualityReview": "Quality Review", + "colFailed": "Failed", + "retryTask": "Retry Task", "colDone": "Done", "recurring": "RECURRING", "spawned": "SPAWNED", diff --git a/messages/es.json b/messages/es.json index b33cee0f4..5aeabf243 100644 --- a/messages/es.json +++ b/messages/es.json @@ -608,6 +608,8 @@ "colInProgress": "En progreso", "colReview": "Revisión", "colQualityReview": "Revisión de calidad", + "colFailed": "Fallido", + "retryTask": "Reintentar", "colDone": "Hecho", "recurring": "RECURRENTE", "spawned": "GENERADO", diff --git a/messages/fr.json b/messages/fr.json index e89cc111d..2a6870a56 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -608,6 +608,8 @@ "colInProgress": "En cours", "colReview": "Révision", "colQualityReview": "Révision qualité", + "colFailed": "Échoué", + "retryTask": "Réessayer", "colDone": "Terminé", "recurring": "RÉCURRENT", "spawned": "GÉNÉRÉ", diff --git a/messages/ja.json b/messages/ja.json index c11ac7319..bcee272e5 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -608,6 +608,8 @@ "colInProgress": "進行中", "colReview": "レビュー", "colQualityReview": "品質レビュー", + "colFailed": "失敗", + "retryTask": "再試行", "colDone": "完了", "recurring": "定期", "spawned": "生成済み", diff --git a/messages/ko.json b/messages/ko.json index 9d6ae817f..829f7bb7b 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -757,6 +757,8 @@ "colInProgress": "진행 중", "colReview": "검토", "colQualityReview": "품질 검토", + "colFailed": "실패", + "retryTask": "재시도", "colDone": "완료", "recurring": "반복", "spawned": "생성됨", diff --git a/messages/pt.json b/messages/pt.json index d0653bb03..b412a2a66 100644 --- a/messages/pt.json +++ b/messages/pt.json @@ -608,6 +608,8 @@ "colInProgress": "Em andamento", "colReview": "Revisão", "colQualityReview": "Revisão de qualidade", + "colFailed": "Falhou", + "retryTask": "Tentar novamente", "colDone": "Concluído", "recurring": "RECORRENTE", "spawned": "GERADO", diff --git a/messages/ru.json b/messages/ru.json index b197af207..6f7378170 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -608,6 +608,8 @@ "colInProgress": "В работе", "colReview": "На проверке", "colQualityReview": "Контроль качества", + "colFailed": "Ошибка", + "retryTask": "Повторить", "colDone": "Готово", "recurring": "ПОВТОРЯЮЩАЯСЯ", "spawned": "СОЗДАНА", diff --git a/messages/zh.json b/messages/zh.json index 066865ce4..8b5398fd7 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -846,6 +846,8 @@ "colInProgress": "进行中", "colReview": "审查", "colQualityReview": "质量审查", + "colFailed": "失败", + "retryTask": "重试", "colDone": "完成", "recurring": "循环", "spawned": "已生成", diff --git a/scripts/mc-mcp-server.cjs b/scripts/mc-mcp-server.cjs index b20217b8d..db3b3daad 100755 --- a/scripts/mc-mcp-server.cjs +++ b/scripts/mc-mcp-server.cjs @@ -298,9 +298,32 @@ const TOOLS = [ // --- Tasks --- { name: 'mc_list_tasks', - description: 'List all tasks in Mission Control', - inputSchema: { type: 'object', properties: {}, required: [] }, - handler: async () => api('GET', '/api/tasks'), + description: 'List tasks in Mission Control with optional filters', + inputSchema: { + type: 'object', + properties: { + status: { type: 'string', description: 'Filter by status: backlog, inbox, assigned, awaiting_owner, in_progress, review, quality_review, done, failed' }, + assigned_to: { type: 'string', description: 'Filter by assigned agent name' }, + priority: { type: 'string', description: 'Filter by priority: low, medium, high, critical' }, + search: { type: 'string', description: 'Search in task title (partial match)' }, + limit: { type: 'number', description: 'Max results (default 50, max 200)' }, + }, + required: [], + }, + handler: async ({ status, assigned_to, priority, search, limit } = {}) => { + const params = new URLSearchParams() + if (status) params.set('status', status) + if (assigned_to) params.set('assigned_to', assigned_to) + if (priority) params.set('priority', priority) + if (limit) params.set('limit', String(Math.min(limit, 200))) + const qs = params.toString() ? `?${params.toString()}` : '' + const result = await api('GET', `/api/tasks${qs}`) + if (search && result?.tasks) { + const term = search.toLowerCase() + result.tasks = result.tasks.filter(t => t.title?.toLowerCase().includes(term)) + } + return result + }, }, { name: 'mc_get_task', diff --git a/scripts/mc-tui.cjs b/scripts/mc-tui.cjs index 1a1a4e427..248d64083 100755 --- a/scripts/mc-tui.cjs +++ b/scripts/mc-tui.cjs @@ -917,7 +917,7 @@ async function handleInputKey(key, str, render) { render(); setTimeout(() => { state.actionMessage = ''; render(); }, 2000); } else if (state.inputMode === 'edit-status') { - const valid = ['backlog', 'inbox', 'assigned', 'in_progress', 'review', 'done', 'failed']; + const valid = ['backlog', 'inbox', 'assigned', 'awaiting_owner', 'in_progress', 'review', 'done', 'failed']; if (!valid.includes(value)) { state.actionMessage = `Invalid status. Use: ${valid.join(', ')}`; state.inputMode = null; diff --git a/src/app/api/tasks/[id]/route.ts b/src/app/api/tasks/[id]/route.ts index 6de2db66a..ee1a1bed1 100644 --- a/src/app/api/tasks/[id]/route.ts +++ b/src/app/api/tasks/[id]/route.ts @@ -7,8 +7,8 @@ import { logger } from '@/lib/logger'; import { validateBody, updateTaskSchema } from '@/lib/validation'; import { resolveMentionRecipients } from '@/lib/mentions'; import { normalizeTaskUpdateStatus } from '@/lib/task-status'; -import { pushTaskToGitHub } from '@/lib/github-sync-engine'; -import { pushTaskToGnap, removeTaskFromGnap } from '@/lib/gnap-sync'; +import { syncTaskOutbound } from '@/lib/github-sync-engine'; +import { removeTaskFromGnap } from '@/lib/gnap-sync'; import { config } from '@/lib/config'; function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined { @@ -391,26 +391,9 @@ export async function PUT( `).get(taskId, workspaceId) as Task; const parsedTask = mapTaskRow(updatedTask); - // Fire-and-forget outbound GitHub sync for relevant changes - const syncRelevantChanges = changes.some(c => - c.startsWith('status:') || c.startsWith('priority:') || c.includes('title') || c.includes('assigned') - ) - if (syncRelevantChanges && (updatedTask as any).github_repo) { - const project = db.prepare(` - SELECT id, github_repo, github_sync_enabled FROM projects - WHERE id = ? AND workspace_id = ? - `).get((updatedTask as any).project_id, workspaceId) as any - if (project?.github_sync_enabled) { - pushTaskToGitHub(updatedTask as any, project).catch(err => - logger.error({ err, taskId }, 'Outbound GitHub sync failed') - ) - } - } - - // Fire-and-forget GNAP sync for task updates - if (config.gnap.enabled && config.gnap.autoSync && changes.length > 0) { - try { pushTaskToGnap(updatedTask as any, config.gnap.repoPath) } - catch (err) { logger.warn({ err, taskId }, 'GNAP sync failed for task update') } + // Fire-and-forget outbound sync (GitHub + GNAP) + if (changes.length > 0) { + syncTaskOutbound(updatedTask as any, workspaceId); } // Broadcast to SSE clients diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index 92a878668..4b176703c 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -7,7 +7,7 @@ import { logger } from '@/lib/logger'; import { validateBody, createTaskSchema, bulkUpdateTaskStatusSchema } from '@/lib/validation'; import { resolveMentionRecipients } from '@/lib/mentions'; import { normalizeTaskCreateStatus } from '@/lib/task-status'; -import { pushTaskToGitHub } from '@/lib/github-sync-engine'; +import { pushTaskToGitHub, syncTaskOutbound } from '@/lib/github-sync-engine'; import { pushTaskToGnap } from '@/lib/gnap-sync'; import { config } from '@/lib/config'; @@ -397,13 +397,19 @@ export async function PUT(request: NextRequest) { transaction(tasks); - // Broadcast status changes to SSE clients + // Broadcast status changes to SSE clients + outbound sync for (const task of tasks) { eventBus.broadcast('task.status_changed', { id: task.id, status: task.status, updated_at: Math.floor(Date.now() / 1000), }); + + // Fire-and-forget outbound sync (GitHub + GNAP) + const fullTask = db.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?').get(task.id, workspaceId) as Task | undefined; + if (fullTask) { + syncTaskOutbound(fullTask as any, workspaceId); + } } return NextResponse.json({ success: true, updated: tasks.length }); diff --git a/src/components/panels/task-board-panel.tsx b/src/components/panels/task-board-panel.tsx index bdc50b675..b897eee3b 100644 --- a/src/components/panels/task-board-panel.tsx +++ b/src/components/panels/task-board-panel.tsx @@ -22,7 +22,7 @@ interface Task { id: number title: string description?: string - status: 'backlog' | 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'done' | 'awaiting_owner' + status: 'backlog' | 'inbox' | 'assigned' | 'awaiting_owner' | 'in_progress' | 'review' | 'quality_review' | 'done' | 'failed' priority: 'low' | 'medium' | 'high' | 'critical' | 'urgent' assigned_to?: string created_by: string @@ -45,6 +45,8 @@ interface Task { github_pr_number?: number github_pr_state?: string comment_count?: number + error_message?: string + dispatch_attempts?: number } interface Agent { @@ -96,6 +98,7 @@ const STATUS_COLUMN_KEYS = [ { key: 'review', titleKey: 'colReview', color: 'bg-purple-500/20 text-purple-400' }, { key: 'quality_review', titleKey: 'colQualityReview', color: 'bg-indigo-500/20 text-indigo-400' }, { key: 'done', titleKey: 'colDone', color: 'bg-green-500/20 text-green-400' }, + { key: 'failed', titleKey: 'colFailed', color: 'bg-red-500/20 text-red-400' }, ] const AWAITING_OWNER_KEYWORDS = [ @@ -1485,6 +1488,33 @@ function TaskDetailModal({ )} + {/* Failed task: error message + retry button */} + {task.status === 'failed' && ( +
+ {task.error_message && ( +

{task.error_message}

+ )} + {task.dispatch_attempts != null && task.dispatch_attempts > 0 && ( +

Dispatch attempts: {task.dispatch_attempts}

+ )} + +
+ )} + {/* Content */}
diff --git a/src/index.ts b/src/index.ts index a0902e3c4..d7cde2cd2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -87,7 +87,7 @@ export interface Task { id: number title: string description?: string - status: 'backlog' | 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'done' + status: 'backlog' | 'inbox' | 'assigned' | 'awaiting_owner' | 'in_progress' | 'review' | 'quality_review' | 'done' | 'failed' priority: 'low' | 'medium' | 'high' | 'urgent' project_id?: number project_ticket_no?: number diff --git a/src/lib/__tests__/github-label-map.test.ts b/src/lib/__tests__/github-label-map.test.ts index b28248c18..93f62cebb 100644 --- a/src/lib/__tests__/github-label-map.test.ts +++ b/src/lib/__tests__/github-label-map.test.ts @@ -43,7 +43,7 @@ describe('labelToStatus', () => { }) it('is the inverse of statusToLabel', () => { - const statuses = ['backlog', 'inbox', 'assigned', 'in_progress', 'review', 'quality_review', 'done'] as const + const statuses = ['backlog', 'inbox', 'assigned', 'awaiting_owner', 'in_progress', 'review', 'quality_review', 'done', 'failed'] as const for (const status of statuses) { expect(labelToStatus(statusToLabel(status).name)).toBe(status) } @@ -88,7 +88,7 @@ describe('labelToPriority', () => { describe('ALL_MC_LABELS', () => { it('contains all status and priority labels', () => { - expect(ALL_MC_LABELS.length).toBe(11) // 7 statuses + 4 priorities + expect(ALL_MC_LABELS.length).toBe(13) // 9 statuses + 4 priorities const names = ALL_MC_LABELS.map(l => l.name) expect(names).toContain('mc:inbox') expect(names).toContain('priority:critical') @@ -103,8 +103,8 @@ describe('ALL_MC_LABELS', () => { }) describe('ALL_STATUS_LABEL_NAMES', () => { - it('contains all 7 status label names', () => { - expect(ALL_STATUS_LABEL_NAMES).toHaveLength(7) + it('contains all 9 status label names', () => { + expect(ALL_STATUS_LABEL_NAMES).toHaveLength(9) expect(ALL_STATUS_LABEL_NAMES).toContain('mc:inbox') expect(ALL_STATUS_LABEL_NAMES).toContain('mc:done') }) diff --git a/src/lib/__tests__/validation.test.ts b/src/lib/__tests__/validation.test.ts index 6c0548a2c..d2c525d37 100644 --- a/src/lib/__tests__/validation.test.ts +++ b/src/lib/__tests__/validation.test.ts @@ -36,7 +36,7 @@ describe('createTaskSchema', () => { }) it('accepts all valid statuses', () => { - for (const status of ['backlog', 'inbox', 'assigned', 'in_progress', 'review', 'quality_review', 'done']) { + for (const status of ['backlog', 'inbox', 'assigned', 'awaiting_owner', 'in_progress', 'review', 'quality_review', 'done', 'failed']) { const result = createTaskSchema.safeParse({ title: 'T', status }) expect(result.success).toBe(true) } diff --git a/src/lib/db.ts b/src/lib/db.ts index 011dda953..50cbacace 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -189,7 +189,7 @@ export interface Task { id: number; title: string; description?: string; - status: 'backlog' | 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'done'; + status: 'backlog' | 'inbox' | 'assigned' | 'awaiting_owner' | 'in_progress' | 'review' | 'quality_review' | 'done' | 'failed'; priority: 'low' | 'medium' | 'high' | 'urgent'; project_id?: number; project_ticket_no?: number; diff --git a/src/lib/event-bus.ts b/src/lib/event-bus.ts index bd808f970..aedca9d7d 100644 --- a/src/lib/event-bus.ts +++ b/src/lib/event-bus.ts @@ -36,6 +36,7 @@ export type EventType = | 'run.updated' | 'run.completed' | 'run.eval_attached' + | 'task.escalated' class ServerEventBus extends EventEmitter { private static instance: ServerEventBus | null = null diff --git a/src/lib/github-label-map.ts b/src/lib/github-label-map.ts index 395631655..4776183ef 100644 --- a/src/lib/github-label-map.ts +++ b/src/lib/github-label-map.ts @@ -3,7 +3,7 @@ * Labels use `mc:` prefix to avoid collisions with existing repo labels. */ -export type TaskStatus = 'backlog' | 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'done' +export type TaskStatus = 'backlog' | 'inbox' | 'assigned' | 'awaiting_owner' | 'in_progress' | 'review' | 'quality_review' | 'done' | 'failed' export type TaskPriority = 'low' | 'medium' | 'high' | 'critical' interface LabelDef { @@ -22,6 +22,8 @@ const STATUS_LABEL_MAP: Record = { review: { name: 'mc:review', color: 'a855f7', description: 'Mission Control: review' }, quality_review: { name: 'mc:quality-review', color: '6366f1', description: 'Mission Control: quality review' }, done: { name: 'mc:done', color: '22c55e', description: 'Mission Control: done' }, + awaiting_owner: { name: 'mc:awaiting-owner', color: 'f97316', description: 'Mission Control: awaiting owner' }, + failed: { name: 'mc:failed', color: 'ef4444', description: 'Mission Control: failed' }, } const LABEL_STATUS_MAP: Record = Object.fromEntries( diff --git a/src/lib/github-sync-engine.ts b/src/lib/github-sync-engine.ts index f2ec3fd46..bcb6259c7 100644 --- a/src/lib/github-sync-engine.ts +++ b/src/lib/github-sync-engine.ts @@ -263,3 +263,40 @@ export async function pullFromGitHub( return { pulled, pushed } } + +/** + * Fire-and-forget outbound sync for a task to GitHub + GNAP. + * Called after any status change — drag-drop, dispatch, Aegis, requeue. + */ +export function syncTaskOutbound( + task: { id: number; title: string; status: string; priority: string; description?: string | null; github_issue_number?: number | null; github_repo?: string | null; project_id?: number | null; workspace_id?: number }, + workspaceId: number +): void { + const db = getDatabase() + try { + // GitHub sync + if (task.project_id) { + const project = db.prepare( + 'SELECT id, github_repo, github_sync_enabled FROM projects WHERE id = ? AND workspace_id = ?' + ).get(task.project_id, workspaceId) as { id: number; github_repo?: string | null; github_sync_enabled?: number | null } | undefined + if (project?.github_sync_enabled) { + pushTaskToGitHub(task as any, project).catch(err => + logger.warn({ err, taskId: task.id }, 'Outbound GitHub sync failed') + ) + } + } + } catch (err) { + logger.warn({ err, taskId: task.id }, 'GitHub sync lookup failed') + } + + try { + // GNAP sync + const { config } = require('@/lib/config') + if (config.gnap?.enabled && config.gnap?.repoPath) { + const { pushTaskToGnap } = require('@/lib/gnap-sync') + pushTaskToGnap(task, config.gnap.repoPath) + } + } catch (err) { + logger.warn({ err, taskId: task.id }, 'GNAP sync failed') + } +} diff --git a/src/lib/gnap-sync.ts b/src/lib/gnap-sync.ts index f69ffbc9f..13d7f0df9 100644 --- a/src/lib/gnap-sync.ts +++ b/src/lib/gnap-sync.ts @@ -25,6 +25,8 @@ const MC_TO_GNAP_STATUS: Record = { quality_review: 'review', completed: 'done', done: 'done', + awaiting_owner: 'blocked', + failed: 'blocked', blocked: 'blocked', cancelled: 'cancelled', } diff --git a/src/lib/task-dispatch.ts b/src/lib/task-dispatch.ts index 6e4e4aad9..9e723e04b 100644 --- a/src/lib/task-dispatch.ts +++ b/src/lib/task-dispatch.ts @@ -4,6 +4,22 @@ import { callOpenClawGateway } from './openclaw-gateway' import { eventBus } from './event-bus' import { logger } from './logger' import { config } from './config' +import { syncTaskOutbound } from './github-sync-engine' + +/** Sync task to GitHub/GNAP and broadcast escalation if task failed */ +function syncAndEscalateIfFailed(task: { id: number; title: string; status: string; priority: string; project_id?: number | null; workspace_id: number; description?: string | null }, newStatus: string, errorMsg?: string, dispatchAttempts?: number): void { + syncTaskOutbound({ ...task, status: newStatus }, task.workspace_id) + if (newStatus === 'failed') { + eventBus.broadcast('task.escalated', { + id: task.id, + title: task.title, + reason: errorMsg?.includes('Aegis rejected') ? 'max_aegis_rejections' : errorMsg?.includes('stuck') ? 'stale_task_max_retries' : 'max_dispatch_retries', + dispatch_attempts: dispatchAttempts ?? 0, + error_message: (errorMsg ?? '').substring(0, 500), + workspace_id: task.workspace_id, + }) + } +} interface DispatchableTask { id: number @@ -59,6 +75,15 @@ function classifyTaskModel(task: DispatchableTask): string | null { return '9router/cc/claude-opus-4-6' } + // Size heuristics → Opus for large/complex tasks + const descLength = (task.description ?? '').length + if (descLength > 2000) return '9router/cc/claude-opus-4-6' + try { + const db = getDatabase() + const row = db.prepare('SELECT estimated_hours FROM tasks WHERE id = ?').get(task.id) as { estimated_hours: number | null } | undefined + if (row?.estimated_hours && row.estimated_hours >= 4) return '9router/cc/claude-opus-4-6' + } catch { /* ignore */ } + // Routine signals → Haiku const routineSignals = [ 'status check', 'health check', 'ping', 'list ', 'fetch ', 'format', @@ -202,6 +227,15 @@ function classifyDirectModel(task: DispatchableTask): string { return 'claude-opus-4-6' } + // Size heuristics → Opus for large/complex tasks + const descLength = (task.description ?? '').length + if (descLength > 2000) return 'claude-opus-4-6' + try { + const db = getDatabase() + const row = db.prepare('SELECT estimated_hours FROM tasks WHERE id = ?').get(task.id) as { estimated_hours: number | null } | undefined + if (row?.estimated_hours && row.estimated_hours >= 4) return 'claude-opus-4-6' + } catch { /* ignore */ } + // Routine → Haiku const routineSignals = [ 'status check', 'health check', 'format', 'rename', 'summarize', @@ -306,10 +340,13 @@ interface ReviewableTask { id: number title: string description: string | null + status: string + priority: string resolution: string | null assigned_to: string | null agent_config: string | null workspace_id: number + project_id: number | null ticket_prefix: string | null project_ticket_no: number | null } @@ -378,8 +415,8 @@ export async function runAegisReviews(): Promise<{ ok: boolean; message: string const db = getDatabase() const tasks = db.prepare(` - SELECT t.id, t.title, t.description, t.resolution, t.assigned_to, t.workspace_id, - p.ticket_prefix, t.project_ticket_no, a.config as agent_config + SELECT t.id, t.title, t.description, t.status, t.priority, t.resolution, t.assigned_to, t.workspace_id, + t.project_id, p.ticket_prefix, t.project_ticket_no, a.config as agent_config FROM tasks t LEFT JOIN projects p ON p.id = t.project_id AND p.workspace_id = t.workspace_id LEFT JOIN agents a ON a.name = t.assigned_to AND a.workspace_id = t.workspace_id @@ -461,6 +498,7 @@ export async function runAegisReviews(): Promise<{ ok: boolean; message: string status: 'done', previous_status: 'quality_review', }) + syncAndEscalateIfFailed(task, 'done') } else { // Rejected: check dispatch_attempts to decide next status const now = Math.floor(Date.now() / 1000) @@ -480,6 +518,7 @@ export async function runAegisReviews(): Promise<{ ok: boolean; message: string error_message: `Aegis rejected ${newAttempts} times`, reason: 'max_aegis_retries_exceeded', }) + syncAndEscalateIfFailed(task, 'failed', `Aegis rejected ${newAttempts} times`, newAttempts) } else { // Requeue to assigned for re-dispatch with feedback db.prepare('UPDATE tasks SET status = ?, error_message = ?, dispatch_attempts = ?, updated_at = ? WHERE id = ?') @@ -492,6 +531,7 @@ export async function runAegisReviews(): Promise<{ ok: boolean; message: string error_message: `Aegis rejected: ${verdict.notes}`, reason: 'aegis_rejection', }) + syncAndEscalateIfFailed(task, 'assigned') } // Add rejection as a comment so the agent sees it on next dispatch @@ -589,6 +629,7 @@ export async function requeueStaleTasks(): Promise<{ ok: boolean; message: strin reason: 'stale_task_max_retries', }) + syncAndEscalateIfFailed(task as any, 'failed', `Task stuck in_progress ${newAttempts} times`, newAttempts) failed++ } else { db.prepare('UPDATE tasks SET status = ?, error_message = ?, dispatch_attempts = ?, updated_at = ? WHERE id = ?') @@ -607,6 +648,7 @@ export async function requeueStaleTasks(): Promise<{ ok: boolean; message: strin error_message: `Agent "${task.assigned_to}" went offline`, reason: 'stale_task_requeue', }) + syncAndEscalateIfFailed(task as any, 'assigned') requeued++ } @@ -804,6 +846,7 @@ export async function dispatchAssignedTasks(): Promise<{ ok: boolean; message: s assigned_to: task.assigned_to, dispatch_session_id: agentResponse.sessionId, }) + syncAndEscalateIfFailed(task, 'review') db_helpers.logActivity( 'task_agent_completed', @@ -838,6 +881,7 @@ export async function dispatchAssignedTasks(): Promise<{ ok: boolean; message: s error_message: `Dispatch failed ${newAttempts} times`, reason: 'max_dispatch_retries_exceeded', }) + syncAndEscalateIfFailed(task, 'failed', `Dispatch failed ${newAttempts} times`, newAttempts) } else { // Revert to assigned so it can be retried on the next tick db.prepare('UPDATE tasks SET status = ?, error_message = ?, dispatch_attempts = ?, updated_at = ? WHERE id = ?') @@ -850,6 +894,7 @@ export async function dispatchAssignedTasks(): Promise<{ ok: boolean; message: s error_message: errorMsg.substring(0, 500), reason: 'dispatch_failed', }) + syncAndEscalateIfFailed(task, 'assigned') } db_helpers.logActivity( @@ -1004,6 +1049,7 @@ export async function autoRouteInboxTasks(): Promise<{ ok: boolean; message: str task.workspace_id) eventBus.broadcast('task.status_changed', { id: task.id, status: 'assigned', previous_status: 'inbox', assigned_to: alt.agent.name }) + syncAndEscalateIfFailed(task as any, 'assigned') routed++ continue } @@ -1017,6 +1063,7 @@ export async function autoRouteInboxTasks(): Promise<{ ok: boolean; message: str task.workspace_id) eventBus.broadcast('task.status_changed', { id: task.id, status: 'assigned', previous_status: 'inbox', assigned_to: best.name }) + syncAndEscalateIfFailed(task as any, 'assigned') routed++ } diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 62766c6c2..2cb66aded 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -34,7 +34,7 @@ const taskMetadataSchema = z.object({ export const createTaskSchema = z.object({ title: z.string().min(1, 'Title is required').max(500), description: z.string().max(5000).optional(), - status: z.enum(['backlog', 'inbox', 'assigned', 'in_progress', 'review', 'quality_review', 'done']).default('inbox'), + status: z.enum(['backlog', 'inbox', 'assigned', 'awaiting_owner', 'in_progress', 'review', 'quality_review', 'done', 'failed']).default('inbox'), priority: z.enum(['critical', 'high', 'medium', 'low']).default('medium'), project_id: z.number().int().positive().optional(), assigned_to: z.string().max(100).optional(), @@ -73,7 +73,7 @@ export const createAgentSchema = z.object({ export const bulkUpdateTaskStatusSchema = z.object({ tasks: z.array(z.object({ id: z.number().int().positive(), - status: z.enum(['backlog', 'inbox', 'assigned', 'in_progress', 'review', 'quality_review', 'done']), + status: z.enum(['backlog', 'inbox', 'assigned', 'awaiting_owner', 'in_progress', 'review', 'quality_review', 'done', 'failed']), })).min(1, 'At least one task is required').max(100), }) diff --git a/src/store/index.ts b/src/store/index.ts index b6d8b09b9..dadfcc0af 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -100,7 +100,7 @@ export interface Task { id: number title: string description?: string - status: 'backlog' | 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'done' | 'awaiting_owner' + status: 'backlog' | 'inbox' | 'assigned' | 'awaiting_owner' | 'in_progress' | 'review' | 'quality_review' | 'done' | 'failed' priority: 'low' | 'medium' | 'high' | 'critical' | 'urgent' project_id?: number project_ticket_no?: number