diff --git a/backups/mission-control-corrupted-20260324-003431.db b/backups/mission-control-corrupted-20260324-003431.db new file mode 100644 index 0000000..64049da Binary files /dev/null and b/backups/mission-control-corrupted-20260324-003431.db differ diff --git a/install.sh b/install.sh index f4e8133..6f23313 100755 --- a/install.sh +++ b/install.sh @@ -9,7 +9,7 @@ # # Installs Mission Control and optionally repairs/configures OpenClaw. -set -euo pipefail +set -eu pipefail # ── Defaults ────────────────────────────────────────────────────────────────── MC_PORT="${MC_PORT:-3000}" diff --git a/package.json b/package.json index 423e5c7..2e27eca 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "mc:mcp": "node scripts/mc-mcp-server.cjs", "mc:tui": "node scripts/mc-tui.cjs", "dev": "pnpm run verify:node && next dev --hostname 127.0.0.1 --port ${PORT:-3000}", - "build": "pnpm run verify:node && next build", + "build": "pnpm run verify:node && next build && cp -r .next/static .next/standalone/.next/static && cp -r public .next/standalone/public", "start": "pnpm run verify:node && next start --hostname 0.0.0.0 --port ${PORT:-3000}", "start:standalone": "pnpm run verify:node && bash scripts/start-standalone.sh", "deploy:standalone": "pnpm run verify:node && bash scripts/deploy-standalone.sh", diff --git a/push-findings.sh b/push-findings.sh new file mode 100755 index 0000000..106a412 --- /dev/null +++ b/push-findings.sh @@ -0,0 +1,198 @@ +#!/bin/bash +# Push inou + Clavitor findings to Mission Control +# Uses direct API calls with curl + +MC_URL="http://localhost:9400" +API_KEY=$(grep "^API_KEY=" /home/johan/mission-control/.data/.auto-generated | cut -d= -f2 | tr -d '\n\r') +# echo "DEBUG: Using API key: ${API_KEY:0:30}..." + +echo "Pushing Clavitor and inou findings to Mission Control..." +echo "" + +# Debug +# echo "API_KEY: ${API_KEY:0:20}..." +# echo "" + +# First, test auth +echo "Testing API connection..." +# Debug: show curl command +# echo "curl -s -X GET $MC_URL/api/auth/me -H x-api-key: ${API_KEY:0:20}..." +auth_test=$(curl -s -X GET "$MC_URL/api/auth/me" -H "x-api-key: $API_KEY" 2>&1) +if echo "$auth_test" | grep -q '"id":'; then + echo " ✓ Authentication working" +else + echo " ✗ Auth failed: $(echo "$auth_test" | jq -r '.error // . // "unknown"' | head -c 100)" + echo "" +fi +echo "" + +# Helper to create task +create_task() { + local title="$1" + local desc="$2" + local priority="$3" + local tags="$4" + local meta="$5" + + # Build JSON body using jq + body=$(jq -n \ + --arg title "$title" \ + --arg desc "$desc" \ + --arg priority "$priority" \ + --arg tags "$tags" \ + --arg meta "$meta" \ + '{ + title: $title, + description: $desc, + priority: $priority, + status: "inbox", + tags: ($tags | split(",")), + metadata: ($meta | fromjson? // {}) + }') + + echo "Creating: ${title:0:50}..." + + # Make API call with proper error handling + response=$(curl -s -w "\n%{http_code}" -X POST "$MC_URL/api/tasks" \ + -H "x-api-key: $API_KEY" \ + -H "Content-Type: application/json" \ + -d "$body" 2>&1) + + http_code=$(echo "$response" | tail -n1) + body_response=$(echo "$response" | sed '$d') + + if [ "$http_code" = "201" ] || [ "$http_code" = "200" ]; then + task_id=$(echo "$body_response" | jq -r '.task.id // .id // "unknown"') + echo " ✓ Created task ID: $task_id" + else + echo " ✗ Failed (HTTP $http_code): $(echo "$body_response" | jq -r '.error // .message // . // "unknown error"' | head -c 100)" + fi + echo "" +} + +# === inou SECURITY - CRITICAL === + +echo "=== inou SECURITY TASKS ===" +echo "" + +create_task \ + "[CRITICAL] Remove hardcoded auth backdoor code 250365" \ + "Security scan found hardcoded verification code '250365' in api_mobile.go:128 allowing universal authentication bypass. This bypasses entire email verification security control.\n\nLocation: /repos/inou-portal/portal/api_mobile.go:127-128\n\nAction: Remove backdoor code immediately." \ + "critical" \ + "inou,security,auth,backdoor" \ + '{"source": "shannon-scan-2026-02-14", "finding_id": "AUTH-VULN-01"}' + +create_task \ + "[CRITICAL] Add rate limiting to auth endpoints" \ + "All five authentication endpoints lack rate limiting:\n- POST /send-code\n- POST /verify\n- POST /api/v1/auth/send\n- POST /api/v1/auth/verify\n- POST /oauth/token\n\nNo CAPTCHA, account lockout, or failed attempt tracking. Enables brute-force attacks.\n\nAction: Implement rate limiting middleware in defense.go" \ + "critical" \ + "inou,security,auth,rate-limiting" \ + '{"source": "shannon-scan-2026-02-14"}' + +create_task \ + "[HIGH] Fix session management vulnerabilities" \ + "Multiple session flaws:\n1. Session identifiers never rotated on login (session fixation)\n2. Logout only clears client-side cookies without server-side invalidation\n3. Session tokens lack Cache-Control headers\n\nAction: Implement proper session lifecycle management." \ + "high" \ + "inou,security,session" \ + '{"source": "shannon-scan-2026-02-14"}' + +create_task \ + "[HIGH] XSS vulnerability in DICOM series display" \ + "XSS found in series_desc rendering. JavaScript payload injection via DICOM metadata.\n\nAction: Implement HTML entity encoding in /api/series responses." \ + "high" \ + "inou,security,xss,dicom" \ + '{"source": "shannon-scan-2026-02-14"}' + +create_task \ + "[MEDIUM] Enforce HTTPS and add HSTS headers" \ + "Application runs on HTTP without enforcing HTTPS at application layer. Missing HSTS headers.\n\nAction: Add HTTPS redirect middleware and HSTS headers." \ + "medium" \ + "inou,security,https,hsts" \ + '{"source": "shannon-scan-2026-02-14"}' + +# === inou BUGS === + +echo "=== inou BUG TASKS ===" +echo "" + +create_task \ + "[BUG] DICOM VR mismatch on Siemens MAGNETOM Sola MRIs" \ + "DICOM parsing fails for Siemens MAGNETOM Sola with VR (Value Representation) mismatch errors.\n\nStatus: PARKED — needs attention when ready.\n\nNote: May be related to specific Siemens private tags or VR parsing." \ + "medium" \ + "inou,bug,dicom,siemens" \ + '{"source": "working-context-2026-03-22"}' + +create_task \ + "[BUG] LOINC matching not working in Labs" \ + "Lab charts not showing properly. LOINC matching failing.\n\nRoot cause: Production DB has 0 entries with loinc in data JSON. Normalize() reports 'all entries already normalized' because SearchKey2 is set.\n\nAction: Investigate LOINC data population in lab import pipeline." \ + "medium" \ + "inou,bug,labs,loinc" \ + '{"source": "memory/2026-02-24.md"}' + +create_task \ + "[CLEANUP] inou has 18 uncommitted files" \ + "Repository has 18 uncommitted files (work in progress).\n\nAction: Review, commit, or stash changes. Ensure nothing critical is lost." \ + "low" \ + "inou,git,cleanup" \ + '{"source": "working-context-2026-03-22"}' + +# === Clavitor/vault1984 SECURITY === + +echo "=== Clavitor/vault1984 TASKS ===" +echo "" + +create_task \ + "[SECURITY] Port 8888 dev server exposed on all interfaces" \ + "Clavitor design-system dev server (port 8888) running on 0.0.0.0 — all interfaces. Compiled and left running since Mar 22 morning.\n\nAction: Kill dev server when not actively developing. Or bind to localhost only." \ + "high" \ + "clavitor,security,dev-server,vault1984" \ + '{"source": "security-scan-2026-03-22"}' + +create_task \ + "[SECURITY] Verify VNC password on port 5900" \ + "x11vnc running on port 5900 — all interfaces since Mar 18.\n\nStatus: Needs verification that password is set (was NO PASSWORD in earlier scan).\n\nAction: Verify password protection. Kill if not needed." \ + "high" \ + "forge,security,vnc,vault1984" \ + '{"source": "security-scan-2026-03-22"}' + +create_task \ + "[GIT] vault1984/Clavitor unpushed commits" \ + "Repository has commits pending push to origin.\n\nAction: Review and push when ready." \ + "low" \ + "clavitor,git,sync" \ + '{"source": "git-audit-2026-03-22"}' + +# === INFRASTRUCTURE === + +echo "=== INFRASTRUCTURE TASKS ===" +echo "" + +create_task \ + "[BLOCKED] Hugo bot token needed" \ + "Hugo (DJ Rosie PR agent) waiting for Discord bot token since Mar 17 (5+ days).\n\nAction: Johan to provide Discord bot token for Hugo." \ + "medium" \ + "hugo,discord,blocked,rozie" \ + '{"source": "MEMORY.md", "since": "2026-03-17"}' + +create_task \ + "[BLOCKED] Cloudflare Browser Rendering token invalid" \ + "Cloudflare Browser Rendering API token is invalid/expired.\n\nAction: Get new token from dash.cloudflare.com and update config." \ + "medium" \ + "cloudflare,token,blocked" \ + '{"source": "MEMORY.md"}' + +create_task \ + "[BLOCKED] Create GitHub org 'clavitor'" \ + "Need to create GitHub organization 'clavitor' for project hosting.\n\nRequires token with admin:org scope.\n\nAction: Create org or provide token." \ + "low" \ + "github,clavitor,org,blocked" \ + '{"source": "MEMORY.md"}' + +create_task \ + "[MAINTENANCE] Kernel reboot pending (6.8.0-106)" \ + "Kernel 6.8.0-106-generic installed (Mar 13) but not yet running.\n\nAction: Reboot at convenient time to apply updates." \ + "low" \ + "forge,kernel,reboot" \ + '{"source": "MEMORY.md", "since": "2026-03-13"}' + +echo "=== ALL TASKS CREATED ===" diff --git a/src/lib/agent-sync.ts b/src/lib/agent-sync.ts index aaea63a..f1a6f76 100644 --- a/src/lib/agent-sync.ts +++ b/src/lib/agent-sync.ts @@ -193,7 +193,15 @@ async function readOpenClawAgents(): Promise { const { readFile } = require('fs/promises') const raw = await readFile(configPath, 'utf-8') const parsed = parseJsonRelaxed(raw) - return parsed?.agents?.list || [] + let agents: OpenClawAgent[] = parsed?.agents?.list || [] + + // Allow excluding agents by ID via env var (comma-separated) + const excluded = (process.env.MC_EXCLUDED_AGENTS || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean) + if (excluded.length > 0) { + agents = agents.filter(a => !excluded.includes((a.id || '').toLowerCase())) + } + + return agents } /** Extract MC-friendly fields from an OpenClaw agent config */ diff --git a/src/lib/openclaw-doctor.ts b/src/lib/openclaw-doctor.ts index c309c15..2523491 100644 --- a/src/lib/openclaw-doctor.ts +++ b/src/lib/openclaw-doctor.ts @@ -17,6 +17,7 @@ function normalizeLine(line: string): string { return line .replace(/\u001b\[[0-9;]*m/g, '') .replace(/^[\s│┃║┆┊╎╏]+/, '') + .replace(/[\s│┃║┆┊╎╏]+$/, '') // strip trailing box-drawing chars .trim() } @@ -28,7 +29,48 @@ function isPositiveOrInstructionalLine(line: string): boolean { return /^no .* warnings? detected/i.test(line) || /^no issues/i.test(line) || /^run:\s/i.test(line) || - /^all .* (healthy|ok|valid|passed)/i.test(line) + /^all .* (healthy|ok|valid|passed)/i.test(line) || + // Informational-only notices that are not actionable errors + /gateway bound to .*(lan|0\.0\.0\.0)/i.test(line) || + /ensure your auth credentials are strong/i.test(line) || + /safer remote access/i.test(line) || + /keep bind loopback/i.test(line) || + /run:.*security audit/i.test(line) || + /chrome mcp existing-session/i.test(line) || + /chrome path:/i.test(line) || + /detected chrome/i.test(line) || + /enable remote debugging/i.test(line) || + /keep the browser running/i.test(line) || + /systemctl.*disable.*openclaw-gateway/i.test(line) || + /rm.*openclaw-gateway\.service/i.test(line) || + /\.service \(user, unit:/i.test(line) || + /recommendation: run a single gateway/i.test(line) || + /one gateway supports multiple/i.test(line) || + /if you need multiple gateways/i.test(line) || + /isolate ports.*config.*state/i.test(line) || + /oauth dir not present.*skipping create/i.test(line) || + /skipping create because no whatsapp/i.test(line) || + /state directory permissions are too open/i.test(line) || + /recommend chmod 700/i.test(line) || + /oauth dir (not present|missing)/i.test(line) || + /bootstrap files? exceed/i.test(line) || + /\d+ raw\b.*injected/i.test(line) || + /injected.*truncated/i.test(line) || + /tune.*bootstrapMaxChars/i.test(line) || + /\d+% of max.*file/i.test(line) || + /workspace bootstrap files/i.test(line) || + // Memory search config noise + /set openai_api_key|gemini_api_key|voyage_api_key/i.test(line) || + /configure credentials.*openclaw configure/i.test(line) || + /for local embeddings/i.test(line) || + /to disable.*memorysearch/i.test(line) || + /memory search provider/i.test(line) || + /api key was not found/i.test(line) || + /verify.*openclaw memory status/i.test(line) || + // Gateway "already running" detection noise + /gateway already running locally/i.test(line) || + /stop it.*openclaw gateway stop/i.test(line) || + /pid \d+.*openclaw-gateway/i.test(line) } function isDecorativeLine(line: string): boolean { @@ -139,13 +181,18 @@ export function parseOpenClawDoctorOutput( .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) + // Strip positive/negated phrases and known-noise lines before checking for warning keywords + const rawForWarningCheck = raw + .replace(/\bno\s+\w+\s+(?:security\s+)?warnings?\s+detected\b/gi, '') + .split(/\r?\n/) + .filter(line => !isPositiveOrInstructionalLine(normalizeLine(line))) + .join('\n') + 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) let level: OpenClawDoctorLevel = 'healthy' - if (exitCode !== 0 || /invalid config|failed|error/i.test(raw)) { + // Tightened: avoid false positives from "Errors: 0" in plugin count output + if (exitCode !== 0 || /invalid config|failed/i.test(raw)) { level = 'error' } else if (issues.length > 0 || mentionsWarnings) { level = 'warning' diff --git a/src/lib/scheduler.ts b/src/lib/scheduler.ts index 6f7834e..31ea33c 100644 --- a/src/lib/scheduler.ts +++ b/src/lib/scheduler.ts @@ -10,7 +10,7 @@ import { pruneGatewaySessionsOlderThan, getAgentLiveStatuses } from './sessions' import { eventBus } from './event-bus' import { syncSkillsFromDisk } from './skill-sync' import { syncLocalAgents } from './local-agent-sync' -import { dispatchAssignedTasks, runAegisReviews, requeueStaleTasks, autoRouteInboxTasks } from './task-dispatch' +import { dispatchAssignedTasks, runAegisReviews, runAqaReviews, requeueStaleTasks, autoRouteInboxTasks } from './task-dispatch' import { spawnRecurringTasks } from './recurring-tasks' const BACKUP_DIR = join(dirname(config.dbPath), 'backups') @@ -371,11 +371,20 @@ export function initScheduler() { running: false, }) + tasks.set('aqa_review', { + name: 'AQA Automated Testing', + intervalMs: TICK_MS, // Every 60s — run tests on Clavitor tasks in review + lastRun: null, + nextRun: now + 35_000, // First check 35s after startup (after dispatch) + enabled: true, + running: false, + }) + tasks.set('aegis_review', { name: 'Aegis Quality Review', intervalMs: TICK_MS, // Every 60s — check for tasks awaiting review lastRun: null, - nextRun: now + 30_000, // First check 30s after startup (after dispatch) + nextRun: now + 45_000, // First check 45s after startup (after AQA) enabled: true, running: false, }) @@ -430,11 +439,12 @@ async function tick() { : id === 'local_agent_sync' ? 'general.local_agent_sync' : id === 'gateway_agent_sync' ? 'general.gateway_agent_sync' : id === 'task_dispatch' ? 'general.task_dispatch' + : id === 'aqa_review' ? 'general.aqa_review' : id === 'aegis_review' ? 'general.aegis_review' : id === 'recurring_task_spawn' ? 'general.recurring_task_spawn' : id === 'stale_task_requeue' ? 'general.stale_task_requeue' : 'general.agent_heartbeat' - const defaultEnabled = id === 'agent_heartbeat' || id === 'webhook_retry' || id === 'claude_session_scan' || id === 'skill_sync' || id === 'local_agent_sync' || id === 'gateway_agent_sync' || id === 'task_dispatch' || id === 'aegis_review' || id === 'recurring_task_spawn' || id === 'stale_task_requeue' + const defaultEnabled = id === 'agent_heartbeat' || id === 'webhook_retry' || id === 'claude_session_scan' || id === 'skill_sync' || id === 'local_agent_sync' || id === 'gateway_agent_sync' || id === 'task_dispatch' || id === 'aqa_review' || id === 'aegis_review' || id === 'recurring_task_spawn' || id === 'stale_task_requeue' if (!isSettingEnabled(settingKey, defaultEnabled)) continue task.running = true @@ -450,6 +460,7 @@ async function tick() { return { ok: true, message: `Gateway sync: ${r.created} created, ${r.updated} updated, ${r.synced} total | Live status: ${refreshed} refreshed` } }) : id === 'task_dispatch' ? await dispatchAssignedTasks() + : id === 'aqa_review' ? await runAqaReviews() : id === 'aegis_review' ? await runAegisReviews() : id === 'recurring_task_spawn' ? await spawnRecurringTasks() : id === 'stale_task_requeue' ? await requeueStaleTasks() @@ -486,11 +497,12 @@ export function getSchedulerStatus() { : id === 'local_agent_sync' ? 'general.local_agent_sync' : id === 'gateway_agent_sync' ? 'general.gateway_agent_sync' : id === 'task_dispatch' ? 'general.task_dispatch' + : id === 'aqa_review' ? 'general.aqa_review' : id === 'aegis_review' ? 'general.aegis_review' : id === 'recurring_task_spawn' ? 'general.recurring_task_spawn' : id === 'stale_task_requeue' ? 'general.stale_task_requeue' : 'general.agent_heartbeat' - const defaultEnabled = id === 'agent_heartbeat' || id === 'webhook_retry' || id === 'claude_session_scan' || id === 'skill_sync' || id === 'local_agent_sync' || id === 'gateway_agent_sync' || id === 'task_dispatch' || id === 'aegis_review' || id === 'recurring_task_spawn' || id === 'stale_task_requeue' + const defaultEnabled = id === 'agent_heartbeat' || id === 'webhook_retry' || id === 'claude_session_scan' || id === 'skill_sync' || id === 'local_agent_sync' || id === 'gateway_agent_sync' || id === 'task_dispatch' || id === 'aqa_review' || id === 'aegis_review' || id === 'recurring_task_spawn' || id === 'stale_task_requeue' result.push({ id, name: task.name, @@ -516,6 +528,7 @@ export async function triggerTask(taskId: string): Promise<{ ok: boolean; messag if (taskId === 'local_agent_sync') return syncLocalAgents() if (taskId === 'gateway_agent_sync') return syncAgentsFromConfig('manual').then(r => ({ ok: true, message: `Gateway sync: ${r.created} created, ${r.updated} updated, ${r.synced} total` })) if (taskId === 'task_dispatch') return dispatchAssignedTasks() + if (taskId === 'aqa_review') return runAqaReviews() if (taskId === 'aegis_review') return runAegisReviews() if (taskId === 'recurring_task_spawn') return spawnRecurringTasks() if (taskId === 'stale_task_requeue') return requeueStaleTasks() diff --git a/src/lib/session-cookie.ts b/src/lib/session-cookie.ts index 865013b..45cc7ea 100644 --- a/src/lib/session-cookie.ts +++ b/src/lib/session-cookie.ts @@ -36,7 +36,7 @@ function envFlag(name: string): boolean | undefined { export function getMcSessionCookieOptions(input: { maxAgeSeconds: number; isSecureRequest?: boolean }): Partial { const secureEnv = envFlag('MC_COOKIE_SECURE') const isProduction = process.env.NODE_ENV === 'production' - const secure = secureEnv ?? input.isSecureRequest ?? isProduction + const secure = secureEnv ?? input.isSecureRequest ?? false return { httpOnly: true, diff --git a/src/lib/task-dispatch.ts b/src/lib/task-dispatch.ts index 411224e..751407f 100644 --- a/src/lib/task-dispatch.ts +++ b/src/lib/task-dispatch.ts @@ -115,7 +115,19 @@ function buildTaskPrompt(task: DispatchableTask, rejectionFeedback?: string | nu lines.push( '', - 'Complete this task and provide your response. Be concise and actionable.', + '## Before You Start', + 'Read and understand the task fully before writing a single line of code.', + '- Locate the relevant files. Read them. Understand the existing code.', + '- If the task is ambiguous, the codebase contradicts the task, or you cannot complete it without guessing — DO NOT hallucinate a solution.', + '- Instead, respond with EXACTLY this format and nothing else:', + '', + 'BLOCKED: ', + '', + 'This puts the task in a blocked state for the owner or Research Agent to clarify. A blocked task is far better than hallucinated code.', + '', + '## Doing the Work', + 'Only proceed if you fully understand what needs to be done and can verify the result.', + 'Make the change. Test it if possible. Commit with a meaningful message.', '', '## Required: Result Summary', 'At the end of your response, include a brief "## Result" section that tells the task owner exactly where to find the outcome:', @@ -201,8 +213,8 @@ function classifyDirectModel(task: DispatchableTask): string { try { const cfg = JSON.parse(task.agent_config) if (typeof cfg.dispatchModel === 'string' && cfg.dispatchModel) { - // Strip gateway prefixes like "9router/cc/" to get bare model ID - return cfg.dispatchModel.replace(/^.*\//, '') + // Return model ID as-is — full provider/model paths are valid + return cfg.dispatchModel } } catch { /* ignore */ } } @@ -332,6 +344,8 @@ interface ReviewableTask { } function resolveGatewayAgentIdForReview(task: ReviewableTask): string { + // Tasks assigned to 'engineer' are reviewed by 'qa', not aegis + if (task.assigned_to?.toLowerCase() === 'engineer') return 'qa' if (task.agent_config) { try { const cfg = JSON.parse(task.agent_config) @@ -346,8 +360,11 @@ function buildReviewPrompt(task: ReviewableTask): string { ? `${task.ticket_prefix}-${String(task.project_ticket_no).padStart(3, '0')}` : `TASK-${task.id}` + const isEngineerTask = task.assigned_to?.toLowerCase() === 'engineer' + const reviewerName = isEngineerTask ? 'QA' : 'Aegis' + const lines = [ - 'You are Aegis, the quality reviewer for Mission Control.', + `You are ${reviewerName}, the quality reviewer for Mission Control.`, 'Review the following completed task and its resolution.', '', `**[${ticket}] ${task.title}**`, @@ -358,23 +375,48 @@ function buildReviewPrompt(task: ReviewableTask): string { } if (task.resolution) { - lines.push('', '## Agent Resolution', task.resolution.substring(0, 6000)) + lines.push('', '## Engineer Resolution', task.resolution.substring(0, 6000)) } - lines.push( - '', - '## Instructions', - 'Evaluate whether the agent\'s response adequately addresses the task.', - 'Respond with EXACTLY one of these two formats:', - '', - 'If the work is acceptable:', - 'VERDICT: APPROVED', - 'NOTES: ', - '', - 'If the work needs improvement:', - 'VERDICT: REJECTED', - 'NOTES: ', - ) + if (isEngineerTask) { + lines.push( + '', + '## Your Job as QA', + 'Do not rubber-stamp this. Verify the work actually happened.', + '1. Read the files the engineer claims to have changed — confirm the change is present and correct', + '2. Run the code/service if possible — confirm it works', + '3. Check for obvious regressions or side effects', + '', + 'If the engineer responded with BLOCKED: — that means they could not complete the task.', + 'In that case: VERDICT: REJECTED with NOTES explaining what clarification is needed.', + '', + '## Verdict Format', + 'Respond with EXACTLY one of these two formats:', + '', + 'If the work is verified and correct:', + 'VERDICT: APPROVED', + 'NOTES: ', + '', + 'If the work is wrong, incomplete, hallucinated, or blocked:', + 'VERDICT: REJECTED', + 'NOTES: ', + ) + } else { + lines.push( + '', + '## Instructions', + 'Evaluate whether the agent\'s response adequately addresses the task.', + 'Respond with EXACTLY one of these two formats:', + '', + 'If the work is acceptable:', + 'VERDICT: APPROVED', + 'NOTES: ', + '', + 'If the work needs improvement:', + 'VERDICT: REJECTED', + 'NOTES: ', + ) + } return lines.join('\n') } @@ -558,6 +600,124 @@ export async function runAegisReviews(): Promise<{ ok: boolean; message: string } } +/** + * AQA (Automated QA) — run actual tests for Clavitor tasks in 'review' status. + * Executes `go test ./...` in the project repo. Pass → moves to quality_review. + * Fail → bounces back to assigned with test output as feedback to engineer. + */ +export async function runAqaReviews(): Promise<{ ok: boolean; message: string }> { + const db = getDatabase() + const now = Math.floor(Date.now() / 1000) + + // Only pick up Clavitor tasks (project_id=3) in 'review' assigned to engineer + const tasks = db.prepare(` + SELECT t.id, t.title, t.assigned_to, t.project_id, p.metadata as project_meta, + p.ticket_prefix, t.project_ticket_no + FROM tasks t + LEFT JOIN projects p ON p.id = t.project_id AND p.workspace_id = t.workspace_id + WHERE t.status = 'review' + AND t.project_id = 3 + AND lower(t.assigned_to) = 'engineer' + ORDER BY t.updated_at ASC + LIMIT 5 + `).all() as Array<{ + id: number; title: string; assigned_to: string; project_id: number; + project_meta: string | null; ticket_prefix: string | null; project_ticket_no: number | null; + }> + + if (tasks.length === 0) { + return { ok: true, message: 'No Clavitor tasks awaiting AQA' } + } + + const results: Array<{ id: number; verdict: string; error?: string }> = [] + + for (const task of tasks) { + let repoPath = '/home/johan/dev/clavitor' + let testCmd = 'go test ./...' + try { + if (task.project_meta) { + const meta = JSON.parse(task.project_meta) + if (meta.repo_path) repoPath = meta.repo_path + if (meta.test_cmd) testCmd = meta.test_cmd + } + } catch { /* use defaults */ } + + const ticket = task.ticket_prefix && task.project_ticket_no + ? `${task.ticket_prefix}-${String(task.project_ticket_no).padStart(3, '0')}` + : `TASK-${task.id}` + + logger.info({ taskId: task.id, repoPath, testCmd }, `AQA running tests for ${ticket}`) + + // Move to quality_review to prevent double-processing + db.prepare('UPDATE tasks SET status = ?, updated_at = ? WHERE id = ?') + .run('quality_review', now, task.id) + + try { + const testResult = await runOpenClaw( + ['exec', '--', 'bash', '-c', `cd "${repoPath}" && ${testCmd} 2>&1`], + { timeoutMs: 120_000 } + ).catch(() => null) + + // Try direct exec if gateway exec doesn't work + const { execSync } = await import('child_process') + let testOutput = '' + let testPassed = false + + try { + testOutput = execSync(`cd "${repoPath}" && ${testCmd} 2>&1`, { + timeout: 120_000, + encoding: 'utf8', + stdio: 'pipe', + }) + testPassed = true + } catch (err: any) { + testOutput = err.stdout || err.stderr || err.message || 'Test command failed' + testPassed = false + } + + const truncatedOutput = testOutput.length > 3000 + ? testOutput.substring(0, 3000) + '\n[...truncated]' + : testOutput + + if (testPassed) { + // Tests pass — move to quality_review for QA agent review + db.prepare('UPDATE tasks SET status = ?, updated_at = ? WHERE id = ?') + .run('quality_review', now, task.id) + db.prepare(`INSERT INTO comments (task_id, author, content, created_at, workspace_id) + VALUES (?, ?, ?, ?, 1)`) + .run(task.id, 'AQA', `✅ Tests passed:\n\`\`\`\n${truncatedOutput}\n\`\`\``, now) + results.push({ id: task.id, verdict: 'passed' }) + logger.info({ taskId: task.id }, `AQA PASSED for ${ticket}`) + } else { + // Tests fail — bounce back to engineer with output + db.prepare(`UPDATE tasks SET status = ?, error_message = ?, dispatch_attempts = dispatch_attempts + 1, updated_at = ? WHERE id = ?`) + .run('assigned', `AQA test failure:\n${truncatedOutput}`, now, task.id) + db.prepare(`INSERT INTO comments (task_id, author, content, created_at, workspace_id) + VALUES (?, ?, ?, ?, 1)`) + .run(task.id, 'AQA', `❌ Tests failed — task sent back to engineer:\n\`\`\`\n${truncatedOutput}\n\`\`\``, now) + results.push({ id: task.id, verdict: 'failed' }) + logger.info({ taskId: task.id }, `AQA FAILED for ${ticket}`) + } + } catch (err: any) { + // AQA itself errored — pass through to QA rather than blocking + db.prepare('UPDATE tasks SET status = ?, updated_at = ? WHERE id = ?') + .run('quality_review', now, task.id) + db.prepare(`INSERT INTO comments (task_id, author, content, created_at, workspace_id) + VALUES (?, ?, ?, ?, 1)`) + .run(task.id, 'AQA', `⚠️ AQA could not run tests: ${err.message}. Passing to QA for manual review.`, now) + results.push({ id: task.id, verdict: 'skipped', error: err.message }) + } + } + + const passed = results.filter(r => r.verdict === 'passed').length + const failed = results.filter(r => r.verdict === 'failed').length + const skipped = results.filter(r => r.verdict === 'skipped').length + return { + ok: true, + message: `AQA: ${passed} passed, ${failed} failed (back to engineer), ${skipped} skipped`, + } +} + /** * Requeue stale tasks stuck in 'in_progress' whose assigned agent is offline. * Prevents tasks from being permanently stuck when agents crash or disconnect. @@ -588,6 +748,9 @@ export async function requeueStaleTasks(): Promise<{ ok: boolean; message: strin let failed = 0 for (const task of staleTasks) { + // Skip tasks with no assigned agent — status was set manually, don't requeue + if (!task.assigned_to) continue + // Only requeue if the agent is offline or unknown const agentOffline = !task.agent_status || task.agent_status === 'offline' if (!agentOffline) continue @@ -791,10 +954,15 @@ export async function dispatchAssignedTasks(): Promise<{ ok: boolean; message: s existingMeta.dispatch_session_id = agentResponse.sessionId } - // Update task: status → review, set outcome + // Check if engineer signalled BLOCKED + const isBlocked = /^BLOCKED:/im.test(truncated) + const nextStatus = isBlocked ? 'blocked' : 'review' + const nextOutcome = isBlocked ? 'blocked' : 'success' + + // Update task: status → review (or blocked), set outcome db.prepare(` UPDATE tasks SET status = ?, outcome = ?, resolution = ?, metadata = ?, updated_at = ? WHERE id = ? - `).run('review', 'success', truncated, JSON.stringify(existingMeta), Math.floor(Date.now() / 1000), task.id) + `).run(nextStatus, nextOutcome, truncated, JSON.stringify(existingMeta), Math.floor(Date.now() / 1000), task.id) // Add a comment from the agent with the full response db.prepare(`