[taskboard] add dispatch control plane

This commit is contained in:
2026-03-06 15:21:19 -08:00
parent 1699f0f2b7
commit be1cf8ca8d
25 changed files with 1594 additions and 292 deletions

View File

@@ -3,6 +3,7 @@ import path from "node:path";
import {
ARCHITECTURE_DOCUMENT,
FLEET_CONFIG,
OPENCLAW_AGENTS_DIR,
OPENCLAW_CONFIG_PATH,
ZEROCLAW_CONTROL_DIR,
@@ -10,7 +11,7 @@ import {
} from "@/lib/fleet-config";
import { all } from "@/lib/db";
import { normalizeTask } from "@/lib/tasks";
import type { AgentStatus, FleetAgent, TaskRecord } from "@/lib/types";
import type { AgentStatus, FleetAgent, TaskEvent, TaskRecord } from "@/lib/types";
type OpenClawAgentConfig = {
id: string;
@@ -58,6 +59,24 @@ function parseRoleFromAgentsMd(content: string) {
return "Host-scoped agent";
}
function deriveHeartbeatTimestamp(heartbeatPath: string, heartbeatMd: string) {
const timestampMatch = heartbeatMd.match(
/(Last Heartbeat|Updated|Timestamp):\s*([0-9TZ:.\-+ ]+)/i,
);
if (timestampMatch) {
const parsed = new Date(timestampMatch[2].trim());
if (!Number.isNaN(parsed.getTime())) {
return parsed.toISOString();
}
}
if (!fs.existsSync(heartbeatPath)) {
return null;
}
return fs.statSync(heartbeatPath).mtime.toISOString();
}
function parseResponsibilities(content: string) {
const sectionMatch = content.match(/## Responsibilities([\s\S]*?)(##|$)/);
return sectionMatch ? parseBulletValues(sectionMatch[1]) : [];
@@ -68,11 +87,13 @@ function readWorkspaceAgent(agentRoot: string, fallbackName: string) {
const agentsMd = readTextFile(path.join(workspaceRoot, "AGENTS.md"));
const toolsMd = readTextFile(path.join(workspaceRoot, "TOOLS.md"));
const identityMd = readTextFile(path.join(workspaceRoot, "IDENTITY.md"));
const heartbeatMd = readTextFile(path.join(workspaceRoot, "HEARTBEAT.md"));
const heartbeatPath = path.join(workspaceRoot, "HEARTBEAT.md");
const heartbeatMd = readTextFile(heartbeatPath);
const tools = parseBulletValues(toolsMd);
const capabilities = parseResponsibilities(agentsMd);
const currentTaskMatch = heartbeatMd.match(/Current Task:\s*(.+)/i);
const heartbeatAt = deriveHeartbeatTimestamp(heartbeatPath, heartbeatMd);
return {
files: ["AGENTS.md", "TOOLS.md", "IDENTITY.md"].filter((fileName) =>
@@ -81,6 +102,7 @@ function readWorkspaceAgent(agentRoot: string, fallbackName: string) {
tools,
capabilities,
currentTask: currentTaskMatch ? currentTaskMatch[1].trim() : null,
heartbeatAt,
role: parseRoleFromAgentsMd(agentsMd),
noteValues: parseBulletValues(identityMd),
workspaceRoot,
@@ -141,6 +163,45 @@ async function fetchTaskBuckets(aliases: string[]) {
};
}
async function fetchAgentEventSummary(aliases: string[]) {
const placeholders = aliases.map(() => "?").join(", ");
const latestEvent = await all<TaskEvent>(
`SELECT * FROM task_events WHERE assignee IN (${placeholders}) ORDER BY created_at DESC LIMIT 1`,
aliases,
);
const failureRows = await all<{ count: number }>(
`SELECT COUNT(*) as count FROM task_events
WHERE assignee IN (${placeholders}) AND event_type = 'dispatch_failed'`,
aliases,
);
return {
lastEvent: latestEvent[0] || null,
failureStreak: failureRows[0]?.count || 0,
};
}
function deriveHeartbeatAgeMinutes(heartbeatAt: string | null) {
if (!heartbeatAt) {
return null;
}
const diffMs = Date.now() - new Date(heartbeatAt).getTime();
return Math.max(0, Math.round(diffMs / 60000));
}
function deriveStatus(activeTaskCount: number, heartbeatAt: string | null): AgentStatus {
if (activeTaskCount > 0) {
return "busy";
}
const heartbeatAge = deriveHeartbeatAgeMinutes(heartbeatAt);
if (heartbeatAge !== null && heartbeatAge > 180) {
return "idle";
}
return "active";
}
async function buildOpenClawAgents() {
const config = readOpenClawConfig();
const agents = config.agents?.list || [];
@@ -154,6 +215,8 @@ async function buildOpenClawAgents() {
agentConfig.identity?.name || agentConfig.name || agentConfig.id,
];
const taskBuckets = await fetchTaskBuckets(aliases);
const eventSummary = await fetchAgentEventSummary(aliases);
const heartbeatAgeMinutes = deriveHeartbeatAgeMinutes(workspace.heartbeatAt);
return {
slug: agentConfig.id,
@@ -165,17 +228,22 @@ async function buildOpenClawAgents() {
role: agentConfig.identity?.theme || workspace.role,
runtimePath: workspace.workspaceRoot || OPENCLAW_AGENTS_DIR,
configPath: OPENCLAW_CONFIG_PATH,
defaultDispatchMethod: "openclaw-swarm",
model: agentConfig.model?.primary || null,
emoji: agentConfig.identity?.emoji || "🦞",
channels: getOpenClawChannels(agentConfig.id, config),
tools: workspace.tools,
capabilities: workspace.capabilities,
files: workspace.files,
status: deriveStatus(taskBuckets.activeTasks.length),
status: deriveStatus(taskBuckets.activeTasks.length, workspace.heartbeatAt),
workload: taskBuckets.activeTasks.length,
activeTasks: taskBuckets.activeTasks,
completedTasks: taskBuckets.completedTasks,
currentTask: workspace.currentTask,
heartbeatAt: workspace.heartbeatAt,
heartbeatAgeMinutes,
lastEvent: eventSummary.lastEvent,
failureStreak: eventSummary.failureStreak,
notes: workspace.noteValues,
} satisfies FleetAgent;
}),
@@ -183,58 +251,38 @@ async function buildOpenClawAgents() {
}
async function buildZeroClawAgents() {
const configuredAgents = [
{
slug: "grizzley-zeroclaw",
assignmentKey: "grizzley-zeroclaw",
aliases: ["grizzley-zeroclaw", "ZeroClaw Grizzley", "grizzley"],
name: "ZeroClaw Grizzley",
host: "grizzley",
role: "Edge host operator for grizzley",
runtimePath: ZEROCLAW_PRIMARY_DIR,
configPath: path.join(ZEROCLAW_PRIMARY_DIR, "config.toml"),
model: "glm-4.7",
emoji: "🛰️",
channels: [
{ label: "Gateway", value: "HTTP gateway :3000" },
{ label: "Access", value: "paired remote gateway via ice" },
],
notes: ["Host-scoped runtime for Traefik, OpenCode, and local services."],
},
{
slug: "ice-zeroclaw",
assignmentKey: "ice-zeroclaw",
aliases: ["ice-zeroclaw", "ZeroClaw Ice", "ZeroClaw Admin", "ice"],
name: "ZeroClaw Ice",
host: "ice",
role: "Control-plane operator for ice",
runtimePath: ZEROCLAW_CONTROL_DIR,
configPath: path.join(ZEROCLAW_CONTROL_DIR, "config.toml"),
model: "glm-5",
emoji: "🧊",
channels: [
{ label: "Telegram", value: "Homelab-Ice topics 11-15" },
{ label: "Gateway", value: "paired webhook + status routing" },
],
notes: ["Control-plane runtime and topic router for remote host delegation."],
},
];
const configuredAgents = FLEET_CONFIG.zeroclawAgents.map((agent) => ({
...agent,
runtimePath:
agent.slug === "grizzley-zeroclaw"
? ZEROCLAW_PRIMARY_DIR
: agent.slug === "ice-zeroclaw"
? ZEROCLAW_CONTROL_DIR
: agent.runtimePath,
}));
return Promise.all(
configuredAgents.map(async (configuredAgent) => {
const workspace = readWorkspaceAgent(configuredAgent.runtimePath, configuredAgent.name);
const taskBuckets = await fetchTaskBuckets(configuredAgent.aliases);
const eventSummary = await fetchAgentEventSummary(configuredAgent.aliases);
const heartbeatAgeMinutes = deriveHeartbeatAgeMinutes(workspace.heartbeatAt);
return {
...configuredAgent,
family: "zeroclaw" as const,
defaultDispatchMethod: configuredAgent.dispatch.method,
tools: workspace.tools,
capabilities: workspace.capabilities,
files: workspace.files,
status: deriveStatus(taskBuckets.activeTasks.length),
status: deriveStatus(taskBuckets.activeTasks.length, workspace.heartbeatAt),
workload: taskBuckets.activeTasks.length,
activeTasks: taskBuckets.activeTasks,
completedTasks: taskBuckets.completedTasks,
currentTask: workspace.currentTask,
heartbeatAt: workspace.heartbeatAt,
heartbeatAgeMinutes,
lastEvent: eventSummary.lastEvent,
failureStreak: eventSummary.failureStreak,
notes: [...configuredAgent.notes, ...workspace.noteValues],
};
}),
@@ -250,6 +298,11 @@ export async function listFleetAgents() {
return [...openclawAgents, ...zeroclawAgents];
}
export async function findAgentByAssignmentKey(assignmentKey: string) {
const agents = await listFleetAgents();
return agents.find((agent) => agent.assignmentKey === assignmentKey || agent.aliases.includes(assignmentKey)) || null;
}
export async function listArchitecture() {
const agents = await listFleetAgents();
return {
@@ -263,6 +316,3 @@ export async function listArchitecture() {
})),
};
}
function deriveStatus(activeTaskCount: number): AgentStatus {
return activeTaskCount > 0 ? "busy" : "active";
}