import fs from "node:fs"; import path from "node:path"; import { promisify } from "node:util"; import { execFile } from "node:child_process"; import { REPO_ACCESS_ROOTS, SWARM_REPO_MAP_FILE, SWARM_TASKS_FILE, SWARM_WORKTREES_DIR, ZEROCLAW_WEBHOOK_TIMEOUT_MS, } from "@/lib/fleet-config"; import { findAgentByAssignmentKey } from "@/lib/agents"; import { appendTaskEvent, findTask, updateTask } from "@/lib/tasks"; import type { DispatchState } from "@/lib/types"; const execFileAsync = promisify(execFile); type DispatchResult = { state: DispatchState; summary: string; detail: string; }; function defaultModelForAgent(agent: string) { switch (agent) { case "opencode": return "zai-coding-plan/glm-4.7"; case "gemini": return "gemini-3.1-pro"; default: return "gpt-5.3-codex"; } } function readRepoMap() { if (!fs.existsSync(SWARM_REPO_MAP_FILE)) { throw new Error(`missing_repo_map:${SWARM_REPO_MAP_FILE}`); } return JSON.parse(fs.readFileSync(SWARM_REPO_MAP_FILE, "utf8")) as Record; } function ensureAllowedRepoPath(repoPath: string) { const resolved = path.resolve(repoPath); const allowed = REPO_ACCESS_ROOTS.some((root) => resolved.startsWith(path.resolve(root))); if (!allowed) { throw new Error(`repo_path_not_allowed:${resolved}`); } return resolved; } function ensureSwarmRegistry() { fs.mkdirSync(path.dirname(SWARM_TASKS_FILE), { recursive: true }); if (!fs.existsSync(SWARM_TASKS_FILE)) { fs.writeFileSync(SWARM_TASKS_FILE, JSON.stringify({ tasks: [] }, null, 2)); } } async function dispatchOpenClawTask(taskId: number) { const task = await findTask(taskId); if (!task) { throw new Error("task_not_found"); } if (!task.repo_slug) { throw new Error("repo_slug_required_for_openclaw_dispatch"); } const repoMap = readRepoMap(); const repoPath = ensureAllowedRepoPath(repoMap[task.repo_slug] || ""); if (!repoPath || !fs.existsSync(path.join(repoPath, ".git"))) { throw new Error(`repo_not_available:${task.repo_slug}`); } const agentName = task.preferred_agent || "codex"; const taskKey = `taskboard-${task.id}`; const repoName = path.basename(repoPath); const worktree = path.join(SWARM_WORKTREES_DIR, repoName, taskKey); const branch = `feat/taskboard-${task.id}`; const baseBranch = task.base_branch || "main"; fs.mkdirSync(path.dirname(worktree), { recursive: true }); if (!fs.existsSync(worktree)) { try { await execFileAsync("git", ["-C", repoPath, "fetch", "origin", baseBranch]); } catch { // Keep going. Many local repos already have the base branch available. } await execFileAsync("git", ["-C", repoPath, "worktree", "add", worktree, "-b", branch, `origin/${baseBranch}`]); } ensureSwarmRegistry(); const registry = JSON.parse(fs.readFileSync(SWARM_TASKS_FILE, "utf8")) as { tasks?: Array> }; const tasks = Array.isArray(registry.tasks) ? registry.tasks : []; const existing = tasks.find((entry) => entry.taskboardTaskId === task.id); if (!existing) { tasks.push({ id: taskKey, agent: agentName, repo: repoName, repoPath, repoSlug: task.repo_slug, worktree, branch, baseBranch, tmuxSession: `${agentName}-${taskKey}`, description: task.description, prompt: `Taskboard task #${task.id}: ${task.title}\n\n${task.description}`, status: "queued", notifyOnComplete: true, attempts: 0, maxAttempts: 3, model: task.model_hint || defaultModelForAgent(agentName), reasoning: task.reasoning_effort || "high", taskboardTaskId: task.id, startedAt: null, completedAt: null, createdAt: Date.now(), checks: { prCreated: false, ciPassed: false, reviewPassed: false, }, }); } fs.writeFileSync(SWARM_TASKS_FILE, JSON.stringify({ tasks }, null, 2)); return { state: "dispatched" as const, summary: `Queued in OpenClaw swarm for ${agentName}`, detail: `${task.repo_slug} -> ${worktree}`, }; } async function dispatchZeroClawTask(taskId: number) { const task = await findTask(taskId); if (!task) { throw new Error("task_not_found"); } const agent = await findAgentByAssignmentKey(task.assignee); if (!agent) { throw new Error("assignee_not_found"); } const urlEnv = agent.slug === "grizzley-zeroclaw" ? "ZEROCLAW_GRIZZLEY_URL" : "ZEROCLAW_ICE_URL"; const tokenEnv = agent.slug === "grizzley-zeroclaw" ? "ZEROCLAW_GRIZZLEY_TOKEN" : "ZEROCLAW_ICE_TOKEN"; const baseUrl = process.env[urlEnv]; if (!baseUrl) { throw new Error(`missing_gateway_url:${urlEnv}`); } const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), ZEROCLAW_WEBHOOK_TIMEOUT_MS); const response = await fetch(`${baseUrl.replace(/\/$/, "")}/webhook`, { method: "POST", headers: { "Content-Type": "application/json", ...(process.env[tokenEnv] ? { Authorization: `Bearer ${process.env[tokenEnv]}` } : {}), }, body: JSON.stringify({ message: `Taskboard task #${task.id}: ${task.title}\nHost: ${task.target_host || agent.host}\nChannel: ${task.target_channel || "n/a"}\n\n${task.description}`, }), signal: controller.signal, }); clearTimeout(timeout); if (!response.ok) { const responseText = await response.text(); throw new Error(`webhook_failed:${response.status}:${responseText}`); } const responseText = await response.text(); return { state: "dispatched" as const, summary: `Posted to ${agent.name} webhook`, detail: responseText || `${agent.host} webhook accepted`, }; } export async function dispatchTask(taskId: number) { const task = await findTask(taskId); if (!task) { throw new Error("task_not_found"); } if (!task.assignee) { throw new Error("assignee_required"); } const agent = await findAgentByAssignmentKey(task.assignee); if (!agent) { throw new Error("assignee_not_found"); } await appendTaskEvent({ taskId, assignee: task.assignee, family: task.family, host: task.target_host, eventType: "dispatch_requested", state: task.dispatch_state, summary: `Dispatch requested for ${agent.name}`, detail: task.description, }); try { const result = agent.family === "openclaw" ? await dispatchOpenClawTask(taskId) : await dispatchZeroClawTask(taskId); const updated = await updateTask(taskId, { status: task.status === "Backlog" ? "Todo" : task.status, dispatch_state: result.state, last_dispatch_at: new Date().toISOString(), last_error: null, }); if (!updated) { throw new Error("task_not_found_after_dispatch"); } await appendTaskEvent({ taskId, assignee: updated.assignee, family: updated.family, host: updated.target_host, eventType: "dispatch_succeeded", state: updated.dispatch_state, summary: result.summary, detail: result.detail, }); return updated; } catch (error) { const message = error instanceof Error ? error.message : String(error); const updated = await updateTask(taskId, { dispatch_state: "failed", last_error: message, }); await appendTaskEvent({ taskId, assignee: task.assignee, family: task.family, host: task.target_host, eventType: "dispatch_failed", state: "failed", summary: "Dispatch failed", detail: message, }); if (!updated) { throw error; } return updated; } }