[taskboard] add dispatch control plane
This commit is contained in:
136
lib/agents.ts
136
lib/agents.ts
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user