diff --git a/messages/ar.json b/messages/ar.json index 7b349347..dc612bb6 100644 --- a/messages/ar.json +++ b/messages/ar.json @@ -596,6 +596,7 @@ "colInProgress": "قيد التنفيذ", "colReview": "المراجعة", "colQualityReview": "مراجعة الجودة", + "colAwaitingOwner": "بانتظار المالك", "colDone": "مكتمل", "recurring": "متكرر", "spawned": "مُنشأ", diff --git a/messages/de.json b/messages/de.json index bf14330a..f1fc3155 100644 --- a/messages/de.json +++ b/messages/de.json @@ -596,6 +596,7 @@ "colInProgress": "In Bearbeitung", "colReview": "Überprüfung", "colQualityReview": "Qualitätsprüfung", + "colAwaitingOwner": "Warten auf Eigentümer", "colDone": "Erledigt", "recurring": "WIEDERKEHREND", "spawned": "ERSTELLT", diff --git a/messages/en.json b/messages/en.json index 3bcee551..51687f7f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -596,6 +596,7 @@ "colInProgress": "In Progress", "colReview": "Review", "colQualityReview": "Quality Review", + "colAwaitingOwner": "Awaiting Owner", "colDone": "Done", "recurring": "RECURRING", "spawned": "SPAWNED", diff --git a/messages/es.json b/messages/es.json index 2ca60e70..0852aad2 100644 --- a/messages/es.json +++ b/messages/es.json @@ -596,6 +596,7 @@ "colInProgress": "En progreso", "colReview": "Revisión", "colQualityReview": "Revisión de calidad", + "colAwaitingOwner": "Esperando al propietario", "colDone": "Hecho", "recurring": "RECURRENTE", "spawned": "GENERADO", diff --git a/messages/fr.json b/messages/fr.json index 49bd01ff..83075a14 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -596,6 +596,7 @@ "colInProgress": "En cours", "colReview": "Révision", "colQualityReview": "Révision qualité", + "colAwaitingOwner": "En attente du propriétaire", "colDone": "Terminé", "recurring": "RÉCURRENT", "spawned": "GÉNÉRÉ", diff --git a/messages/ja.json b/messages/ja.json index 36e0e70e..e551a793 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -596,6 +596,7 @@ "colInProgress": "進行中", "colReview": "レビュー", "colQualityReview": "品質レビュー", + "colAwaitingOwner": "オーナー対応待ち", "colDone": "完了", "recurring": "定期", "spawned": "生成済み", diff --git a/messages/ko.json b/messages/ko.json index f126ee0c..f007e863 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -596,6 +596,7 @@ "colInProgress": "진행 중", "colReview": "검토", "colQualityReview": "품질 검토", + "colAwaitingOwner": "소유자 대기 중", "colDone": "완료", "recurring": "반복", "spawned": "생성됨", diff --git a/messages/pt.json b/messages/pt.json index 649a4d38..1bcc79e7 100644 --- a/messages/pt.json +++ b/messages/pt.json @@ -596,6 +596,7 @@ "colInProgress": "Em andamento", "colReview": "Revisão", "colQualityReview": "Revisão de qualidade", + "colAwaitingOwner": "Aguardando proprietário", "colDone": "Concluído", "recurring": "RECORRENTE", "spawned": "GERADO", diff --git a/messages/ru.json b/messages/ru.json index 63209c48..a7a29a98 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -596,6 +596,7 @@ "colInProgress": "В работе", "colReview": "На проверке", "colQualityReview": "Контроль качества", + "colAwaitingOwner": "Ожидает владельца", "colDone": "Готово", "recurring": "ПОВТОРЯЮЩАЯСЯ", "spawned": "СОЗДАНА", diff --git a/messages/zh.json b/messages/zh.json index 45ddf379..12140f52 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -833,6 +833,7 @@ "colInProgress": "进行中", "colReview": "审查", "colQualityReview": "质量审查", + "colAwaitingOwner": "等待负责人", "colDone": "完成", "recurring": "循环", "spawned": "已生成", diff --git a/src/app/[[...panel]]/page.tsx b/src/app/[[...panel]]/page.tsx index 84da7f71..f779d076 100644 --- a/src/app/[[...panel]]/page.tsx +++ b/src/app/[[...panel]]/page.tsx @@ -35,6 +35,8 @@ import { DebugPanel } from '@/components/panels/debug-panel' import { SecurityAuditPanel } from '@/components/panels/security-audit-panel' import { NodesPanel } from '@/components/panels/nodes-panel' import { ExecApprovalPanel } from '@/components/panels/exec-approval-panel' +import { ActiveRunsPanel } from '@/components/panels/active-runs-panel' +import { AgentPerformancePanel } from '@/components/panels/agent-performance-panel' import { ChatPagePanel } from '@/components/panels/chat-page-panel' import { ChatPanel } from '@/components/chat/chat-panel' import { getPluginPanel } from '@/lib/plugins' @@ -562,8 +564,13 @@ function ContentRouter({ tab }: { tab: string }) { return case 'security': return + case 'agent-performance': + case 'performance': + return case 'debug': return + case 'active-runs': + return case 'exec-approvals': if (isLocal) return return diff --git a/src/app/api/active-runs/route.ts b/src/app/api/active-runs/route.ts new file mode 100644 index 00000000..a27aa4fa --- /dev/null +++ b/src/app/api/active-runs/route.ts @@ -0,0 +1,294 @@ +import { NextRequest, NextResponse } from 'next/server' +import { runCommand } from '@/lib/command' +import { requireRole } from '@/lib/auth' +import { logger } from '@/lib/logger' + +export interface ActiveRun { + id: string + name: string + type: 'tmux' | 'systemd' | 'cron' | 'process' + owner: string + status: 'running' | 'stopped' | 'failed' | 'stale' + pid?: string + startedAt?: string + lastProgress?: string + outputSnippet?: string + unit?: string + logPath?: string +} + +const STALE_THRESHOLD_MS = 30 * 60 * 1000 // 30 minutes + +export async function GET(request: NextRequest) { + const auth = requireRole(request, 'viewer') + if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + + try { + const [tmux, systemd, cron, processes] = await Promise.allSettled([ + scanTmuxSessions(), + scanSystemdUnits(), + scanCronJobs(), + scanKnownProcesses(), + ]) + + const runs: ActiveRun[] = [ + ...(tmux.status === 'fulfilled' ? tmux.value : []), + ...(systemd.status === 'fulfilled' ? systemd.value : []), + ...(cron.status === 'fulfilled' ? cron.value : []), + ...(processes.status === 'fulfilled' ? processes.value : []), + ] + + const activeCount = runs.filter(r => r.status === 'running').length + const staleCount = runs.filter(r => r.status === 'stale').length + const stoppedCount = runs.filter(r => r.status === 'stopped' || r.status === 'failed').length + + return NextResponse.json({ + runs, + summary: { total: runs.length, active: activeCount, stale: staleCount, stopped: stoppedCount }, + scannedAt: new Date().toISOString(), + }) + } catch (error) { + logger.error({ err: error }, 'Active runs scan error') + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +async function scanTmuxSessions(): Promise { + const runs: ActiveRun[] = [] + + // Try custom socket first (OpenClaw convention), then default + const sockets = ['~/.tmux/sock', ''] + for (const sock of sockets) { + try { + const args = sock + ? ['-S', sock.replace('~', process.env.HOME || ''), 'list-sessions', '-F', '#{session_name}|#{session_created}|#{session_activity}|#{session_windows}'] + : ['list-sessions', '-F', '#{session_name}|#{session_created}|#{session_activity}|#{session_windows}'] + + const { stdout, code } = await runCommand('tmux', args, { timeoutMs: 5000 }) + if (code !== 0 || !stdout.trim()) continue + + for (const line of stdout.trim().split('\n')) { + const [name, created, activity, windows] = line.split('|') + if (!name) continue + + const createdMs = parseInt(created) * 1000 + const activityMs = parseInt(activity) * 1000 + const isStale = Date.now() - activityMs > STALE_THRESHOLD_MS + + // Grab last few lines from the active pane + let snippet = '' + try { + const captureArgs = sock + ? ['-S', sock.replace('~', process.env.HOME || ''), 'capture-pane', '-t', name, '-p'] + : ['capture-pane', '-t', name, '-p'] + const { stdout: paneOutput } = await runCommand('tmux', captureArgs, { timeoutMs: 3000 }) + const lines = paneOutput.trim().split('\n').filter(l => l.trim()) + snippet = lines.slice(-5).join('\n') + } catch { /* ignore */ } + + runs.push({ + id: `tmux-${name}`, + name, + type: 'tmux', + owner: inferOwner(name), + status: isStale ? 'stale' : 'running', + startedAt: new Date(createdMs).toISOString(), + lastProgress: new Date(activityMs).toISOString(), + outputSnippet: snippet || undefined, + }) + } + } catch { + // tmux not running or socket unavailable + } + } + + // Deduplicate by name (custom socket sessions may overlap with default) + const seen = new Set() + return runs.filter(r => { + if (seen.has(r.name)) return false + seen.add(r.name) + return true + }) +} + +async function scanSystemdUnits(): Promise { + const runs: ActiveRun[] = [] + + try { + // List user units matching known patterns + const { stdout, code } = await runCommand('systemctl', [ + '--user', 'list-units', '--type=service', '--all', '--no-pager', '--plain', + '--no-legend', + ], { timeoutMs: 5000 }) + + if (code !== 0) return runs + + for (const line of stdout.trim().split('\n')) { + if (!line.trim()) continue + const parts = line.trim().split(/\s+/) + const unit = parts[0] || '' + const load = parts[1] || '' + const active = parts[2] || '' + const sub = parts[3] || '' + + // Only show relevant units (btc5m, openclaw, collector, etc.) + if (!isRelevantUnit(unit)) continue + + let status: ActiveRun['status'] = 'stopped' + if (active === 'active' && sub === 'running') status = 'running' + else if (active === 'failed') status = 'failed' + + // Get unit details + let startedAt: string | undefined + let snippet: string | undefined + try { + const { stdout: showOut } = await runCommand('systemctl', [ + '--user', 'show', unit, + '--property=ActiveEnterTimestamp,MainPID', + ], { timeoutMs: 3000 }) + + const tsMatch = showOut.match(/ActiveEnterTimestamp=(.+)/) + if (tsMatch?.[1] && tsMatch[1] !== 'n/a') { + startedAt = new Date(tsMatch[1]).toISOString() + } + } catch { /* ignore */ } + + // Get recent journal output + try { + const { stdout: journalOut } = await runCommand('journalctl', [ + '--user', '-u', unit, '--no-pager', '-n', '5', '--output=short', + ], { timeoutMs: 3000 }) + const lines = journalOut.trim().split('\n').filter(l => l.trim()) + snippet = lines.slice(-5).join('\n') + } catch { /* ignore */ } + + runs.push({ + id: `systemd-${unit}`, + name: unit.replace('.service', ''), + type: 'systemd', + owner: inferOwner(unit), + status, + unit, + startedAt, + outputSnippet: snippet || undefined, + }) + } + } catch { + // systemd --user not available + } + + return runs +} + +async function scanCronJobs(): Promise { + const runs: ActiveRun[] = [] + + try { + const { stdout, code } = await runCommand('crontab', ['-l'], { timeoutMs: 5000 }) + if (code !== 0) return runs + + let idx = 0 + for (const line of stdout.trim().split('\n')) { + if (!line.trim() || line.trim().startsWith('#')) continue + + // Parse cron line: schedule + command + const match = line.match(/^(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+(.+)$/) + if (!match) continue + + const schedule = match[1] + const command = match[2] + idx++ + + // Extract a readable name from the command + const name = extractCronName(command) + + runs.push({ + id: `cron-${idx}`, + name, + type: 'cron', + owner: inferOwner(command), + status: 'running', // cron jobs are always "scheduled" + outputSnippet: `Schedule: ${schedule}\nCommand: ${command.substring(0, 200)}`, + }) + } + } catch { + // crontab not available + } + + return runs +} + +async function scanKnownProcesses(): Promise { + const runs: ActiveRun[] = [] + + try { + const { stdout } = await runCommand('ps', [ + '-eo', 'pid,etimes,args', '--no-headers', + ], { timeoutMs: 5000 }) + + const patterns = [ + { pattern: /btc_5m_latency/, owner: 'ralph', label: 'btc-5m-latency collector' }, + { pattern: /scanner_cron|profit_exit/, owner: 'ralph', label: 'polymarket scanner' }, + { pattern: /collect\.py.*dashboard/, owner: 'obsidian', label: 'dashboard collector' }, + { pattern: /xpost|xqueue/, owner: 'ralph', label: 'X post queue' }, + { pattern: /mc-report/, owner: 'system', label: 'MC reporter' }, + ] + + for (const line of stdout.trim().split('\n')) { + if (!line.trim()) continue + const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/) + if (!match) continue + + const [, pid, etimeStr, command] = match + const elapsed = parseInt(etimeStr) * 1000 + + for (const p of patterns) { + if (p.pattern.test(command)) { + const startedAt = new Date(Date.now() - elapsed).toISOString() + const isStale = elapsed > STALE_THRESHOLD_MS && !p.pattern.test('cron') + + runs.push({ + id: `proc-${pid}`, + name: p.label, + type: 'process', + owner: p.owner, + status: isStale ? 'stale' : 'running', + pid, + startedAt, + lastProgress: startedAt, + outputSnippet: command.substring(0, 300), + }) + break + } + } + } + } catch { + // ps not available + } + + return runs +} + +function inferOwner(name: string): string { + const lower = name.toLowerCase() + if (/ralph|btc|paper|trade|scanner|xpost|xmeme|meme/.test(lower)) return 'ralph' + if (/obsidian|collect|dashboard|mc-/.test(lower)) return 'obsidian' + if (/sentinel|metar|weather|monitor/.test(lower)) return 'sentinel' + return 'system' +} + +function isRelevantUnit(unit: string): boolean { + const lower = unit.toLowerCase() + return /btc5m|openclaw|collector|paper|scanner|sentinel|obsidian|ralph|metar|xpost/.test(lower) +} + +function extractCronName(command: string): string { + // Try to get a meaningful name from the command + const scriptMatch = command.match(/([\/\w-]+\.(sh|py|js))\b/) + if (scriptMatch) { + const parts = scriptMatch[1].split('/') + return parts[parts.length - 1] + } + // Fallback: first 40 chars + return command.substring(0, 40).trim() +} diff --git a/src/app/api/agent-performance/route.ts b/src/app/api/agent-performance/route.ts new file mode 100644 index 00000000..53e2813d --- /dev/null +++ b/src/app/api/agent-performance/route.ts @@ -0,0 +1,119 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getDatabase } from '@/lib/db' +import { requireRole } from '@/lib/auth' +import { logger } from '@/lib/logger' + +interface AgentPerformance { + agent: string + totalTasks: number + completedTasks: number + rejectedTasks: number + successRate: number + rejectionRate: number + avgCompletionHours: number | null + tasksByStatus: Record + recentCompletions: Array<{ title: string; completed_at: number; outcome: string }> +} + +export async function GET(request: NextRequest) { + const auth = requireRole(request, 'viewer') + if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + + try { + const db = getDatabase() + const workspaceId = auth.user.workspace_id ?? 1 + const { searchParams } = new URL(request.url) + const days = parseInt(searchParams.get('days') || '30', 10) + const since = Math.floor(Date.now() / 1000) - days * 86400 + + // Get all agents with task stats + const agents = db.prepare(` + SELECT DISTINCT assigned_to FROM tasks + WHERE workspace_id = ? AND assigned_to IS NOT NULL AND assigned_to != '' + `).all(workspaceId) as Array<{ assigned_to: string }> + + const performances: AgentPerformance[] = [] + + for (const { assigned_to: agent } of agents) { + // Total tasks assigned + const totalRow = db.prepare(` + SELECT COUNT(*) as c FROM tasks + WHERE workspace_id = ? AND assigned_to = ? AND created_at > ? + `).get(workspaceId, agent, since) as { c: number } + + // Completed tasks + const completedRow = db.prepare(` + SELECT COUNT(*) as c FROM tasks + WHERE workspace_id = ? AND assigned_to = ? AND status = 'done' AND created_at > ? + `).get(workspaceId, agent, since) as { c: number } + + // Tasks by status + const statusRows = db.prepare(` + SELECT status, COUNT(*) as c FROM tasks + WHERE workspace_id = ? AND assigned_to = ? AND created_at > ? + GROUP BY status + `).all(workspaceId, agent, since) as Array<{ status: string; c: number }> + + const tasksByStatus: Record = {} + for (const row of statusRows) { + tasksByStatus[row.status] = row.c + } + + // Rejection count from quality_reviews + const rejectedRow = db.prepare(` + SELECT COUNT(*) as c FROM quality_reviews qr + JOIN tasks t ON qr.task_id = t.id + WHERE t.workspace_id = ? AND t.assigned_to = ? AND qr.status = 'rejected' AND qr.created_at > ? + `).get(workspaceId, agent, since) as { c: number } + + // Average completion time (created_at to completed_at) + const avgRow = db.prepare(` + SELECT AVG(completed_at - created_at) as avg_seconds FROM tasks + WHERE workspace_id = ? AND assigned_to = ? AND status = 'done' + AND completed_at IS NOT NULL AND created_at > ? + `).get(workspaceId, agent, since) as { avg_seconds: number | null } + + // Recent completions + const recentRows = db.prepare(` + SELECT title, completed_at, outcome FROM tasks + WHERE workspace_id = ? AND assigned_to = ? AND status = 'done' + AND completed_at IS NOT NULL AND created_at > ? + ORDER BY completed_at DESC LIMIT 5 + `).all(workspaceId, agent, since) as Array<{ title: string; completed_at: number; outcome: string }> + + const total = totalRow.c + const completed = completedRow.c + const rejected = rejectedRow.c + + performances.push({ + agent, + totalTasks: total, + completedTasks: completed, + rejectedTasks: rejected, + successRate: total > 0 ? Math.round((completed / total) * 100) : 0, + rejectionRate: total > 0 ? Math.round((rejected / total) * 100) : 0, + avgCompletionHours: avgRow.avg_seconds != null + ? Math.round((avgRow.avg_seconds / 3600) * 10) / 10 + : null, + tasksByStatus, + recentCompletions: recentRows, + }) + } + + // Sort by completed tasks descending + performances.sort((a, b) => b.completedTasks - a.completedTasks) + + // Aggregate totals + const totals = { + totalTasks: performances.reduce((s, p) => s + p.totalTasks, 0), + completedTasks: performances.reduce((s, p) => s + p.completedTasks, 0), + rejectedTasks: performances.reduce((s, p) => s + p.rejectedTasks, 0), + agents: performances.length, + } + + return NextResponse.json({ performances, totals, days, generatedAt: new Date().toISOString() }) + } catch (error) { + logger.error({ err: error }, 'Agent performance API error') + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/src/components/dashboard/dashboard.tsx b/src/components/dashboard/dashboard.tsx index b45168f2..52ed7382 100644 --- a/src/components/dashboard/dashboard.tsx +++ b/src/components/dashboard/dashboard.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useCallback } from 'react' +import { useState, useCallback, useEffect } from 'react' import { useMissionControl } from '@/store' import { useNavigateToPanel } from '@/lib/navigation' import { useSmartPoll } from '@/lib/use-smart-poll' @@ -43,6 +43,7 @@ export function Dashboard() { const [claudeStats, setClaudeStats] = useState(null) const [githubStats, setGithubStats] = useState(null) const [hermesCronJobCount, setHermesCronJobCount] = useState(0) + const [activeRunsSummary, setActiveRunsSummary] = useState<{ active: number; stale: number } | null>(null) const [loading, setLoading] = useState({ system: true, sessions: true, @@ -114,6 +115,17 @@ export function Dashboard() { setLoading(prev => ({ ...prev, claude: false, github: false })) } + // Active runs scan (lightweight, always fetch) + requests.push( + fetch('/api/active-runs') + .then(async (res) => { + if (!res.ok) return + const data = await res.json() + if (data?.summary) setActiveRunsSummary({ active: data.summary.active, stale: data.summary.stale }) + }) + .catch(() => {}) + ) + await Promise.allSettled(requests) }, [isLocal, setSessions]) @@ -281,6 +293,14 @@ export function Dashboard() { 0 ? 'warning' : 'success'} /> 10 ? 'warning' : 'info'} /> 0 ? 'warning' : 'success'} /> + {activeRunsSummary && ( + 0 ? ` (${activeRunsSummary.stale} stale)` : ''}`} + tone={activeRunsSummary.stale > 0 ? 'warning' : 'success'} + onClick={() => navigateToPanel('active-runs')} + /> + )} diff --git a/src/components/dashboard/sidebar.tsx b/src/components/dashboard/sidebar.tsx index 24b646c4..f54d3a13 100644 --- a/src/components/dashboard/sidebar.tsx +++ b/src/components/dashboard/sidebar.tsx @@ -54,6 +54,7 @@ const menuItems: MenuItem[] = [ { id: 'standup', label: 'Daily Standup', icon: '📈', description: 'Generate standup reports' }, { id: 'spawn', label: 'Spawn Agent', icon: '🚀', description: 'Launch new sub-agents' }, { id: 'logs', label: 'Logs', icon: '📝', description: 'Real-time log viewer' }, + { id: 'active-runs', label: 'Active Runs', icon: '🔄', description: 'Machine processes & sessions' }, { id: 'cron', label: 'Cron Jobs', icon: '⏰', description: 'Automated tasks' }, { id: 'memory', label: 'Memory', icon: '🧠', description: 'Knowledge browser' }, { id: 'tokens', label: 'Tokens', icon: '💰', description: 'Usage & cost tracking' }, diff --git a/src/components/dashboard/widget-primitives.tsx b/src/components/dashboard/widget-primitives.tsx index 56ffeaaa..1c3622d8 100644 --- a/src/components/dashboard/widget-primitives.tsx +++ b/src/components/dashboard/widget-primitives.tsx @@ -118,10 +118,11 @@ export function MetricCard({ label, value, total, subtitle, icon, color }: { ) } -export function SignalPill({ label, value, tone }: { +export function SignalPill({ label, value, tone, onClick }: { label: string value: string tone: 'success' | 'warning' | 'info' + onClick?: () => void }) { const toneClass = tone === 'success' ? 'bg-green-500/15 border-green-500/30 text-green-300' @@ -129,11 +130,12 @@ export function SignalPill({ label, value, tone }: { ? 'bg-amber-500/15 border-amber-500/30 text-amber-300' : 'bg-blue-500/15 border-blue-500/30 text-blue-300' + const Wrapper = onClick ? 'button' : 'div' return ( -
+
{label}
{value}
-
+ ) } diff --git a/src/components/layout/nav-rail.tsx b/src/components/layout/nav-rail.tsx index 878eb6f3..a13c5e1e 100644 --- a/src/components/layout/nav-rail.tsx +++ b/src/components/layout/nav-rail.tsx @@ -45,6 +45,8 @@ const navGroups: NavGroup[] = [ { id: 'logs', label: 'Logs', icon: , priority: false, essential: true }, { id: 'cost-tracker', label: 'Cost Tracker', icon: , priority: false }, { id: 'nodes', label: 'Nodes', icon: , priority: false }, + { id: 'active-runs', label: 'Runs', icon: , priority: true }, + { id: 'agent-performance', label: 'Performance', icon: , priority: false }, { id: 'exec-approvals', label: 'Approvals', icon: , priority: false }, { id: 'office', label: 'Office', icon: , priority: false }, ], @@ -1500,3 +1502,22 @@ function PluginIcon() { ) } + +function ActiveRunsIcon() { + return ( + + + + + + ) +} + +function PerformanceIcon() { + return ( + + + + + ) +} diff --git a/src/components/layout/openclaw-doctor-banner.tsx b/src/components/layout/openclaw-doctor-banner.tsx index faddac2e..ac93d485 100644 --- a/src/components/layout/openclaw-doctor-banner.tsx +++ b/src/components/layout/openclaw-doctor-banner.tsx @@ -99,8 +99,8 @@ export function OpenClawDoctorBanner() { } } - const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000 - const dismissed = doctorDismissedAt != null && (Date.now() - doctorDismissedAt) < TWENTY_FOUR_HOURS + const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000 + const dismissed = doctorDismissedAt != null && (Date.now() - doctorDismissedAt) < SEVEN_DAYS if (loading || dismissed || !doctor || doctor.healthy) return null diff --git a/src/components/panels/active-runs-panel.tsx b/src/components/panels/active-runs-panel.tsx new file mode 100644 index 00000000..a9d030eb --- /dev/null +++ b/src/components/panels/active-runs-panel.tsx @@ -0,0 +1,348 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { Button } from '@/components/ui/button' +import { Loader } from '@/components/ui/loader' +import { createClientLogger } from '@/lib/client-logger' + +const log = createClientLogger('ActiveRuns') + +interface ActiveRun { + id: string + name: string + type: 'tmux' | 'systemd' | 'cron' | 'process' + owner: string + status: 'running' | 'stopped' | 'failed' | 'stale' + pid?: string + startedAt?: string + lastProgress?: string + outputSnippet?: string + unit?: string + logPath?: string +} + +interface ActiveRunsResponse { + runs: ActiveRun[] + summary: { total: number; active: number; stale: number; stopped: number } + scannedAt: string +} + +const TYPE_ICONS: Record = { + tmux: 'T', + systemd: 'S', + cron: 'C', + process: 'P', +} + +const TYPE_LABELS: Record = { + tmux: 'tmux session', + systemd: 'systemd unit', + cron: 'cron job', + process: 'background process', +} + +const STATUS_STYLES: Record = { + running: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/40', + stale: 'bg-amber-500/20 text-amber-400 border-amber-500/40', + stopped: 'bg-zinc-500/20 text-zinc-400 border-zinc-500/40', + failed: 'bg-red-500/20 text-red-400 border-red-500/40', +} + +const OWNER_STYLES: Record = { + ralph: 'bg-blue-500/15 text-blue-400', + obsidian: 'bg-purple-500/15 text-purple-400', + sentinel: 'bg-cyan-500/15 text-cyan-400', + system: 'bg-zinc-500/15 text-zinc-400', +} + +type FilterType = 'all' | 'tmux' | 'systemd' | 'cron' | 'process' +type FilterStatus = 'all' | 'running' | 'stale' | 'stopped' | 'failed' + +export function ActiveRunsPanel() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [expandedRun, setExpandedRun] = useState(null) + const [filterType, setFilterType] = useState('all') + const [filterStatus, setFilterStatus] = useState('all') + + const fetchRuns = useCallback(async () => { + try { + const res = await fetch('/api/active-runs') + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const json = await res.json() + setData(json) + setError(null) + } catch (err) { + log.error('Failed to fetch active runs:', err) + setError('Failed to load active runs') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchRuns() + const interval = setInterval(fetchRuns, 30000) // refresh every 30s + return () => clearInterval(interval) + }, [fetchRuns]) + + if (loading && !data) { + return ( +
+ +
+ ) + } + + if (error && !data) { + return ( +
+
{error}
+ +
+ ) + } + + const summary = data?.summary || { total: 0, active: 0, stale: 0, stopped: 0 } + const runs = (data?.runs || []).filter(r => { + if (filterType !== 'all' && r.type !== filterType) return false + if (filterStatus !== 'all' && r.status !== filterStatus) return false + return true + }) + + // Sort: running first, then stale, then stopped/failed + const sortOrder: Record = { running: 0, stale: 1, failed: 2, stopped: 3 } + runs.sort((a, b) => (sortOrder[a.status] ?? 4) - (sortOrder[b.status] ?? 4)) + + return ( +
+ {/* Header */} +
+
+

Active Runs

+

+ Machine processes, sessions, and scheduled jobs +

+
+
+ {data?.scannedAt && ( + + Scanned {formatRelativeTime(data.scannedAt)} + + )} + +
+
+ + {/* Summary Cards */} +
+ + + + +
+ + {/* Filters */} +
+ setFilterType(v as FilterType)} + options={[ + { value: 'all', label: 'All' }, + { value: 'tmux', label: 'tmux' }, + { value: 'systemd', label: 'systemd' }, + { value: 'cron', label: 'cron' }, + { value: 'process', label: 'process' }, + ]} + /> +
+ setFilterStatus(v as FilterStatus)} + options={[ + { value: 'all', label: 'All' }, + { value: 'running', label: 'Running' }, + { value: 'stale', label: 'Stale' }, + { value: 'stopped', label: 'Stopped' }, + { value: 'failed', label: 'Failed' }, + ]} + /> +
+ + {/* Runs List */} + {runs.length === 0 ? ( +
+ No runs found matching filters +
+ ) : ( +
+ {runs.map(run => ( + setExpandedRun(expandedRun === run.id ? null : run.id)} + /> + ))} +
+ )} +
+ ) +} + +function SummaryCard({ label, value, color }: { label: string; value: number; color: string }) { + return ( +
+
{value}
+
{label}
+
+ ) +} + +function FilterGroup({ + label, + value, + onChange, + options, +}: { + label: string + value: string + onChange: (v: string) => void + options: { value: string; label: string }[] +}) { + return ( +
+ {label}: + {options.map(opt => ( + + ))} +
+ ) +} + +function RunCard({ run, expanded, onToggle }: { run: ActiveRun; expanded: boolean; onToggle: () => void }) { + return ( +
+ {/* Header Row */} + + + {/* Expanded Details */} + {expanded && ( +
+ {run.startedAt && ( + + )} + {run.lastProgress && ( + + )} + {run.unit && ( + + )} + {run.logPath && ( + + )} + {run.outputSnippet && ( +
+
Latest Output
+
+                {run.outputSnippet}
+              
+
+ )} +
+ )} +
+ ) +} + +function DetailRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ) +} + +function formatRelativeTime(iso: string): string { + const ms = Date.now() - new Date(iso).getTime() + if (ms < 0) return 'just now' + const seconds = Math.floor(ms / 1000) + if (seconds < 60) return `${seconds}s ago` + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + return `${days}d ago` +} diff --git a/src/components/panels/agent-performance-panel.tsx b/src/components/panels/agent-performance-panel.tsx new file mode 100644 index 00000000..d2ee59f2 --- /dev/null +++ b/src/components/panels/agent-performance-panel.tsx @@ -0,0 +1,259 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { Button } from '@/components/ui/button' +import { Loader } from '@/components/ui/loader' +import { createClientLogger } from '@/lib/client-logger' + +const log = createClientLogger('AgentPerformance') + +interface AgentPerformance { + agent: string + totalTasks: number + completedTasks: number + rejectedTasks: number + successRate: number + rejectionRate: number + avgCompletionHours: number | null + tasksByStatus: Record + recentCompletions: Array<{ title: string; completed_at: number; outcome: string }> +} + +interface PerformanceResponse { + performances: AgentPerformance[] + totals: { totalTasks: number; completedTasks: number; rejectedTasks: number; agents: number } + days: number + generatedAt: string +} + +const AGENT_COLORS: Record = { + ralph: 'border-blue-500/40 bg-blue-500/10', + obsidian: 'border-purple-500/40 bg-purple-500/10', + sentinel: 'border-cyan-500/40 bg-cyan-500/10', +} + +const AGENT_BADGE: Record = { + ralph: 'bg-blue-500/20 text-blue-400', + obsidian: 'bg-purple-500/20 text-purple-400', + sentinel: 'bg-cyan-500/20 text-cyan-400', +} + +export function AgentPerformancePanel() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [days, setDays] = useState(30) + const [expandedAgent, setExpandedAgent] = useState(null) + + const fetchData = useCallback(async () => { + try { + const res = await fetch(`/api/agent-performance?days=${days}`) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const json = await res.json() + setData(json) + setError(null) + } catch (err) { + log.error('Failed to fetch performance data:', err) + setError('Failed to load performance data') + } finally { + setLoading(false) + } + }, [days]) + + useEffect(() => { + fetchData() + const interval = setInterval(fetchData, 60000) + return () => clearInterval(interval) + }, [fetchData]) + + if (loading && !data) { + return
+ } + + if (error && !data) { + return ( +
+
{error}
+ +
+ ) + } + + const totals = data?.totals || { totalTasks: 0, completedTasks: 0, rejectedTasks: 0, agents: 0 } + const performances = data?.performances || [] + + return ( +
+ {/* Header */} +
+
+

Agent Performance

+

+ Success rate, completion time, and rejection rate per agent +

+
+
+ {[7, 14, 30, 90].map(d => ( + + ))} + +
+
+ + {/* Summary Cards */} +
+ + + + +
+ + {/* Agent Cards */} + {performances.length === 0 ? ( +
+ No agent performance data for the last {days} days +
+ ) : ( +
+ {performances.map(perf => ( + setExpandedAgent(expandedAgent === perf.agent ? null : perf.agent)} + /> + ))} +
+ )} +
+ ) +} + +function SummaryCard({ label, value, color }: { label: string; value: number; color: string }) { + return ( +
+
{value}
+
{label}
+
+ ) +} + +function AgentCard({ perf, expanded, onToggle }: { perf: AgentPerformance; expanded: boolean; onToggle: () => void }) { + const colorClass = AGENT_COLORS[perf.agent] || 'border-zinc-500/40 bg-zinc-500/10' + const badgeClass = AGENT_BADGE[perf.agent] || 'bg-zinc-500/20 text-zinc-400' + + return ( +
+ + + {expanded && ( +
+ {/* Status breakdown */} +
+
Tasks by Status
+
+ {Object.entries(perf.tasksByStatus).map(([status, count]) => ( + + {status}: {count} + + ))} +
+
+ + {/* Recent completions */} + {perf.recentCompletions.length > 0 && ( +
+
Recent Completions
+
+ {perf.recentCompletions.map((task, i) => ( +
+ + {task.title} + + {formatRelativeTime(task.completed_at * 1000)} + +
+ ))} +
+
+ )} +
+ )} +
+ ) +} + +function StatCell({ label, value, good, bad }: { label: string; value: string; good?: boolean; bad?: boolean }) { + return ( +
+
+ {value} +
+
{label}
+
+ ) +} + +function formatRelativeTime(ms: number): string { + const diff = Date.now() - ms + if (diff < 0) return 'just now' + const seconds = Math.floor(diff / 1000) + if (seconds < 60) return `${seconds}s ago` + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + return `${days}d ago` +} diff --git a/src/components/panels/task-board-panel.tsx b/src/components/panels/task-board-panel.tsx index d7a276fc..a8dc841d 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: 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'done' + status: 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'awaiting_owner' | 'done' priority: 'low' | 'medium' | 'high' | 'critical' | 'urgent' assigned_to?: string created_by: string @@ -92,6 +92,7 @@ const STATUS_COLUMN_KEYS = [ { key: 'in_progress', titleKey: 'colInProgress', color: 'bg-yellow-500/20 text-yellow-400' }, { 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: 'awaiting_owner', titleKey: 'colAwaitingOwner', color: 'bg-amber-500/20 text-amber-400' }, { key: 'done', titleKey: 'colDone', color: 'bg-green-500/20 text-green-400' }, ] @@ -463,7 +464,7 @@ export function TaskBoardPanel() { const previousStatus = draggedTask.status try { - if (newStatus === 'done') { + if (newStatus === 'done' && draggedTask.status !== 'awaiting_owner') { const reviewResponse = await fetch(`/api/quality-review?taskId=${draggedTask.id}`) if (!reviewResponse.ok) { throw new Error('Unable to verify Aegis approval') @@ -2134,6 +2135,7 @@ function EditTaskModal({ +
diff --git a/src/lib/csp.ts b/src/lib/csp.ts index e30fc291..885a4a75 100644 --- a/src/lib/csp.ts +++ b/src/lib/csp.ts @@ -6,8 +6,8 @@ export function buildMissionControlCsp(input: { nonce: string; googleEnabled: bo `base-uri 'self'`, `object-src 'none'`, `frame-ancestors 'none'`, - `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' blob:${googleEnabled ? ' https://accounts.google.com' : ''}`, - `style-src 'self' 'nonce-${nonce}'`, + `script-src 'self' 'unsafe-inline' 'nonce-${nonce}' 'strict-dynamic' blob:${googleEnabled ? ' https://accounts.google.com' : ''}`, + `style-src 'self' 'unsafe-inline'`, `connect-src 'self' ws: wss: http://127.0.0.1:* http://localhost:* https://cdn.jsdelivr.net`, `img-src 'self' data: blob:${googleEnabled ? ' https://*.googleusercontent.com https://lh3.googleusercontent.com' : ''}`, `font-src 'self' data:`, diff --git a/src/lib/db.ts b/src/lib/db.ts index cbaea882..dfc7743a 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -173,7 +173,7 @@ export interface Task { id: number; title: string; description?: string; - status: 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'done'; + status: 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'awaiting_owner' | 'done'; priority: 'low' | 'medium' | 'high' | 'urgent'; project_id?: number; project_ticket_no?: number; diff --git a/src/lib/github-label-map.ts b/src/lib/github-label-map.ts index d68aa7f7..bdd44d1e 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 = 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'done' +export type TaskStatus = 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'awaiting_owner' | 'done' export type TaskPriority = 'low' | 'medium' | 'high' | 'critical' interface LabelDef { @@ -19,8 +19,9 @@ const STATUS_LABEL_MAP: Record = { assigned: { name: 'mc:assigned', color: '3b82f6', description: 'Mission Control: assigned' }, in_progress: { name: 'mc:in-progress', color: 'eab308', description: 'Mission Control: in progress' }, 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' }, + quality_review: { name: 'mc:quality-review', color: '6366f1', description: 'Mission Control: quality review' }, + awaiting_owner: { name: 'mc:awaiting-owner', color: 'f59e0b', description: 'Mission Control: awaiting owner' }, + done: { name: 'mc:done', color: '22c55e', description: 'Mission Control: done' }, } const LABEL_STATUS_MAP: Record = Object.fromEntries( diff --git a/src/lib/openclaw-doctor.ts b/src/lib/openclaw-doctor.ts index c309c153..42dd1816 100644 --- a/src/lib/openclaw-doctor.ts +++ b/src/lib/openclaw-doctor.ts @@ -31,6 +31,27 @@ function isPositiveOrInstructionalLine(line: string): boolean { /^all .* (healthy|ok|valid|passed)/i.test(line) } +/** Issues that are known false positives or non-actionable in this environment */ +function isSuppressedIssue(line: string): boolean { + // Strip trailing box-drawing chars and whitespace for matching + const clean = line.replace(/[\s│┃║┆┊╎╏|]+$/g, '').toLowerCase() + return ( + clean.includes('mission-control.service') || + clean.includes('systemctl --user disable') || + clean.includes('openclaw-gateway.service') || + clean.includes('requiremention=false') || + clean.includes('telegram bot api privacy') || + clean.includes('unmentioned group messages') || + clean.includes('botfather') || + clean.includes('setprivacy') || + clean.includes('single gateway') || + clean.includes('multiple gateways') || + clean.includes('gateway recommendation') || + clean.includes('cleanup hints') || + clean.includes('gateway-like services') + ) +} + function isDecorativeLine(line: string): boolean { return /^[▄█▀░\s]+$/.test(line) || /openclaw doctor/i.test(line) || /🦞\s*openclaw\s*🦞/i.test(line) } @@ -137,19 +158,24 @@ export function parseOpenClawDoctorOutput( const issues = lines .filter(line => /^[-*]\s+/.test(line)) .map(line => line.replace(/^[-*]\s+/, '').trim()) - .filter(line => !isSessionAgingLine(line) && !isStateDirectoryListLine(line) && !isPositiveOrInstructionalLine(line)) - - // Strip positive/negated phrases before checking for warning keywords - const rawForWarningCheck = raw.replace(/\bno\s+\w+\s+(?:security\s+)?warnings?\s+detected\b/gi, '') - const mentionsWarnings = /\bwarning|warnings|problem|problems|invalid config|fix\b/i.test(rawForWarningCheck) + .filter(line => !isSessionAgingLine(line) && !isStateDirectoryListLine(line) && !isPositiveOrInstructionalLine(line) && !isSuppressedIssue(line)) + + // Strip positive/negated phrases and section headers before checking for warning keywords + const rawForWarningCheck = raw + .replace(/\bno\s+\w+\s+(?:security\s+)?warnings?\s+detected\b/gi, '') + .replace(/\bchannel warnings?\b/gi, '') // section header, not an actual warning + .replace(/\berrors?:\s*0\b/gi, '') // "Errors: 0" is not an error + .replace(/\bdoctor warnings?\b/gi, '') // section header + const mentionsWarnings = /\bwarning|warnings|problem|problems|invalid config\b/i.test(rawForWarningCheck) const mentionsHealthy = /\bok\b|\bhealthy\b|\bno issues\b|\bno\b.*\bwarnings?\s+detected\b|\bvalid\b/i.test(raw) + // Only flag real issues — if all issues were suppressed, treat as healthy let level: OpenClawDoctorLevel = 'healthy' - if (exitCode !== 0 || /invalid config|failed|error/i.test(raw)) { + if (exitCode !== 0 || /\binvalid config\b|\bconfig invalid\b/i.test(raw)) { level = 'error' - } else if (issues.length > 0 || mentionsWarnings) { - level = 'warning' - } else if (!mentionsHealthy && lines.length > 0) { + } else if (issues.length > 0) { + level = mentionsWarnings ? 'warning' : 'warning' + } else if (mentionsWarnings && !mentionsHealthy) { level = 'warning' } diff --git a/src/lib/schema.sql b/src/lib/schema.sql index ed09fa2a..e14ee05d 100644 --- a/src/lib/schema.sql +++ b/src/lib/schema.sql @@ -6,7 +6,7 @@ CREATE TABLE IF NOT EXISTS tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, description TEXT, - status TEXT NOT NULL DEFAULT 'inbox', -- inbox, assigned, in_progress, review, quality_review, done + status TEXT NOT NULL DEFAULT 'inbox', -- inbox, assigned, in_progress, review, quality_review, awaiting_owner, done priority TEXT NOT NULL DEFAULT 'medium', -- low, medium, high, urgent assigned_to TEXT, -- agent session key created_by TEXT NOT NULL DEFAULT 'system', diff --git a/src/lib/task-dispatch.ts b/src/lib/task-dispatch.ts index ddf402d7..b084baff 100644 --- a/src/lib/task-dispatch.ts +++ b/src/lib/task-dispatch.ts @@ -216,6 +216,19 @@ function buildReviewPrompt(task: ReviewableTask): string { return lines.join('\n') } +const OWNER_ACTION_KEYWORDS = [ + 'owner action', 'you need to', 'manual step', 'browser login', + 'create account', 'purchase', 'sign up', 'login required', + 'action required', 'requires human', 'cannot be automated', +] + +/** Check whether a resolution's text indicates human follow-up is needed. */ +function resolutionRequiresOwnerAction(resolution: string | null | undefined): boolean { + if (!resolution) return false + const lower = resolution.toLowerCase() + return OWNER_ACTION_KEYWORDS.some(kw => lower.includes(kw)) +} + function parseReviewVerdict(text: string): { status: 'approved' | 'rejected'; notes: string } { const upper = text.toUpperCase() const status = upper.includes('VERDICT: APPROVED') ? 'approved' as const : 'rejected' as const @@ -264,25 +277,15 @@ export async function runAegisReviews(): Promise<{ ok: boolean; message: string // Resolve the gateway agent ID from config, falling back to assigned_to or default const reviewAgent = resolveGatewayAgentIdForReview(task) - const invokeParams = { - message: prompt, - agentId: reviewAgent, - idempotencyKey: `aegis-review-${task.id}-${Date.now()}`, - deliver: false, - } - // Use --expect-final to block until the agent completes and returns the full - // response payload (payloads[0].text). The two-step agent → agent.wait pattern - // only returns lifecycle metadata (runId/status/timestamps) and never includes - // the agent's actual text, so Aegis could never parse a verdict. + // Use `openclaw agent` directly — more reliable than gateway WebSocket call const finalResult = await runOpenClaw( - ['gateway', 'call', 'agent', '--expect-final', '--timeout', '120000', '--params', JSON.stringify(invokeParams), '--json'], + ['agent', '--agent', reviewAgent, '--message', prompt, '--timeout', '120'], { timeoutMs: 125_000 } ) - const finalPayload = parseGatewayJson(finalResult.stdout) - ?? parseGatewayJson(String((finalResult as any)?.stderr || '')) - const agentResponse = parseAgentResponse( - finalPayload?.result ? JSON.stringify(finalPayload.result) : finalResult.stdout - ) + const agentResponse: AgentResponseParsed = { + text: finalResult.stdout.trim() || null, + sessionId: null, + } if (!agentResponse.text) { throw new Error('Aegis review returned empty response') } @@ -296,22 +299,46 @@ export async function runAegisReviews(): Promise<{ ok: boolean; message: string `).run(task.id, verdict.status, verdict.notes, task.workspace_id) if (verdict.status === 'approved') { + const finalStatus = resolutionRequiresOwnerAction(task.resolution) ? 'awaiting_owner' : 'done' + db.prepare('UPDATE tasks SET status = ?, updated_at = ? WHERE id = ?') - .run('done', Math.floor(Date.now() / 1000), task.id) + .run(finalStatus, Math.floor(Date.now() / 1000), task.id) eventBus.broadcast('task.status_changed', { id: task.id, - status: 'done', + status: finalStatus, previous_status: 'quality_review', }) + + if (finalStatus === 'awaiting_owner') { + db_helpers.logActivity( + 'task_awaiting_owner', + 'task', + task.id, + 'aegis', + `Task "${task.title}" approved but requires owner action`, + { notes: verdict.notes }, + task.workspace_id + ) + // Notify owner via MC notification + db_helpers.createNotification( + 'admin', + 'awaiting_owner', + 'Owner action needed', + `Task "${task.title}" needs your attention: ${verdict.notes || 'approved but requires manual step'}`, + 'task', + task.id, + task.workspace_id + ) + } } else { - // Rejected: push back to in_progress with feedback + // Rejected: push back to assigned so dispatcher re-sends with feedback db.prepare('UPDATE tasks SET status = ?, error_message = ?, updated_at = ? WHERE id = ?') - .run('in_progress', `Aegis rejected: ${verdict.notes}`, Math.floor(Date.now() / 1000), task.id) + .run('assigned', `Aegis rejected: ${verdict.notes}`, Math.floor(Date.now() / 1000), task.id) eventBus.broadcast('task.status_changed', { id: task.id, - status: 'in_progress', + status: 'assigned', previous_status: 'quality_review', }) @@ -427,32 +454,15 @@ export async function dispatchAssignedTasks(): Promise<{ ok: boolean; message: s // Step 1: Invoke via gateway const gatewayAgentId = resolveGatewayAgentId(task) - const dispatchModel = classifyTaskModel(task) - const invokeParams: Record = { - message: prompt, - agentId: gatewayAgentId, - idempotencyKey: `task-dispatch-${task.id}-${Date.now()}`, - deliver: false, - } - // Route to appropriate model tier based on task complexity. - // null = no override, agent uses its own configured default model. - if (dispatchModel) invokeParams.model = dispatchModel - - // Use --expect-final to block until the agent completes and returns the full - // response payload (result.payloads[0].text). The two-step agent → agent.wait - // pattern only returns lifecycle metadata and never includes the agent's text. + // Use `openclaw agent` directly — more reliable than gateway WebSocket call const finalResult = await runOpenClaw( - ['gateway', 'call', 'agent', '--expect-final', '--timeout', '120000', '--params', JSON.stringify(invokeParams), '--json'], + ['agent', '--agent', gatewayAgentId, '--message', prompt, '--timeout', '120'], { timeoutMs: 125_000 } ) - const finalPayload = parseGatewayJson(finalResult.stdout) - ?? parseGatewayJson(String((finalResult as any)?.stderr || '')) - const agentResponse = parseAgentResponse( - finalPayload?.result ? JSON.stringify(finalPayload.result) : finalResult.stdout - ) - if (!agentResponse.sessionId && finalPayload?.result?.meta?.agentMeta?.sessionId) { - agentResponse.sessionId = finalPayload.result.meta.agentMeta.sessionId + const agentResponse: AgentResponseParsed = { + text: finalResult.stdout.trim() || null, + sessionId: null, } if (!agentResponse.text) { diff --git a/src/lib/validation.ts b/src/lib/validation.ts index c85301cb..6ecc7cb2 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(['inbox', 'assigned', 'in_progress', 'review', 'quality_review', 'done']).default('inbox'), + status: z.enum(['inbox', 'assigned', 'in_progress', 'review', 'quality_review', 'awaiting_owner', 'done']).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(['inbox', 'assigned', 'in_progress', 'review', 'quality_review', 'done']), + status: z.enum(['inbox', 'assigned', 'in_progress', 'review', 'quality_review', 'awaiting_owner', 'done']), })).min(1, 'At least one task is required').max(100), }) diff --git a/src/store/index.ts b/src/store/index.ts index 658343ff..16d8e496 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -98,7 +98,7 @@ export interface Task { id: number title: string description?: string - status: 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'done' + status: 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'awaiting_owner' | 'done' priority: 'low' | 'medium' | 'high' | 'critical' | 'urgent' project_id?: number project_ticket_no?: number