[taskboard] add dispatch control plane
This commit is contained in:
254
lib/dispatch.ts
Normal file
254
lib/dispatch.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
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<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}`);
|
||||
}
|
||||
|
||||
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<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,
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user