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