Files
openclaw-taskboard/lib/dispatch.ts

267 lines
7.9 KiB
TypeScript

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_HOST_WORKTREES_DIR,
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<string, string>;
}
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}`);
}
await execFileAsync("git", ["config", "--global", "--add", "safe.directory", repoPath]);
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 hostWorktree = path.join(SWARM_HOST_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<Record<string, unknown>> };
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: hostWorktree,
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,
},
});
} else {
existing.repoPath = repoPath;
existing.repoSlug = task.repo_slug;
existing.worktree = hostWorktree;
existing.branch = branch;
existing.baseBranch = baseBranch;
existing.agent = agentName;
existing.tmuxSession = `${agentName}-${taskKey}`;
}
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} -> ${hostWorktree}`,
};
}
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;
}
}