overmind-mcp
Version:
Orchestrateur universel agents IA multi-modeles via MCP. Inclut le protocole 'Custom-Nickname' pour identifier vos agents avec des surnoms originaux (The Chaos Prophet, Shadow Sniper, etc.), l'isolation mémoire (Private Memory Context) et le support pour
325 lines • 14.3 kB
JavaScript
/**
* KanbanAdapter — Thin wrapper around `hermes kanban` CLI.
*
* ╔════════════════════════════════════════════════════════════════════════╗
* ║ PURPOSE (v3.0 — Refactored) ║
* ║ ║
* ║ Replaces the old swarm.ts (368 lines) and the parallel orchestration ║
* ║ in dispatcher.ts with native Hermes Kanban tasks. ║
* ║ ║
* ║ Kanban = durable SQLite-backed task board shared across all Hermes ║
* ║ profiles. The dispatcher loop (60s default) atomically claims ready ║
* ║ tasks and spawns the assigned profile as its own OS process. ║
* ║ ║
* ║ YOLO CONFIG (no human-in-the-loop): ║
* ║ - autoReclaim on crash/timeout ║
* ║ - maxRetries: 5 (vs default 2) before block ║
* ║ - autoUnblock via admin API (no manual human intervention) ║
* ║ - dispatcherTick: 30s (vs default 60s — faster pickup) ║
* ╚════════════════════════════════════════════════════════════════════════╝
*/
import { exec } from 'child_process';
import { promisify } from 'util';
import { rootLogger } from '../lib/logger.js';
const execAsync = promisify(exec);
const logger = rootLogger.child({ module: 'KanbanAdapter' });
// ─── YOLO Config ──────────────────────────────────────────────────────────────
export const YOLO_CONFIG = {
autoReclaim: true,
maxRetries: 5,
autoUnblock: true,
reclaimStaleMs: 900000, // 15min
dispatcherTickSeconds: 30,
goalModeMaxTurns: 20,
};
// ─── Helper: run hermes kanban CLI ────────────────────────────────────────────
async function runKanban(args, opts) {
const timeout = opts?.timeout ?? 30000;
const cmd = `hermes kanban ${args.join(' ')}`;
try {
const result = await execAsync(cmd, { timeout, maxBuffer: 5 * 1024 * 1024 });
return { stdout: result.stdout, stderr: result.stderr };
}
catch (e) {
const err = e;
logger.error({
cmd,
error: err.message,
stdout: err.stdout?.slice(0, 500),
stderr: err.stderr?.slice(0, 500),
}, '[KANBAN] CLI command failed.');
throw new Error(`Kanban CLI failed: ${err.message}`, { cause: e });
}
}
// ─── Main Class ───────────────────────────────────────────────────────────────
export class KanbanAdapter {
board;
constructor(board) {
this.board = board;
}
get boardArgs() {
return this.board ? ['--board', this.board] : [];
}
/**
* Initialize the kanban board (idempotent).
*/
async init() {
await runKanban([...this.boardArgs, 'init']);
logger.info({ board: this.board }, '[INIT] Kanban board initialized.');
}
/**
* Force a dispatcher tick immediately (don't wait for the 60s loop).
*/
async nudge() {
try {
await runKanban([...this.boardArgs, 'dispatch'], { timeout: 10000 });
}
catch (e) {
logger.warn({ error: e }, '[NUDGE] Dispatcher tick failed — will retry on next interval.');
}
}
/**
* Create a single task.
*/
async createTask(opts) {
const args = [...this.boardArgs, 'create'];
// Title (positional)
args.push(`"${opts.title.replace(/"/g, '\\"')}"`);
if (opts.assignee)
args.push('--assignee', opts.assignee);
if (opts.body)
args.push('--body', `"${opts.body.replace(/"/g, '\\"')}"`);
if (opts.tenant)
args.push('--tenant', opts.tenant);
if (opts.priority !== undefined)
args.push('--priority', String(opts.priority));
if (opts.workspace)
args.push('--workspace', opts.workspace);
if (opts.idempotencyKey)
args.push('--idempotency-key', opts.idempotencyKey);
if (opts.maxRuntime)
args.push('--max-runtime', opts.maxRuntime);
if (opts.goalMode) {
args.push('--goal');
args.push('--goal-max-turns', String(opts.goalMaxTurns || YOLO_CONFIG.goalModeMaxTurns));
}
if (opts.skills) {
for (const skill of opts.skills) {
args.push('--skill', skill);
}
}
if (opts.parents && opts.parents.length > 0) {
for (const p of opts.parents) {
args.push('--parent', p);
}
}
// YOLO: set high retry limit
args.push('--max-retries', String(YOLO_CONFIG.maxRetries));
args.push('--json');
const { stdout } = await runKanban(args);
const parsed = JSON.parse(stdout.trim());
const taskId = parsed.id || parsed.task_id || '';
if (!taskId) {
throw new Error(`Kanban create did not return a task ID. Output: ${stdout.slice(0, 200)}`);
}
logger.info({ taskId, title: opts.title, assignee: opts.assignee }, '[CREATE] Task created.');
// Nudge dispatcher immediately for faster pickup
void this.nudge();
return { taskId };
}
/**
* Create multiple independent tasks in parallel (fan-out).
*/
async createParallelTasks(tasks) {
const results = await Promise.all(tasks.map((t) => this.createTask(t)));
return results.map((r) => r.taskId);
}
/**
* Create a pipeline (sequential chain: task[0] → task[1] → task[2]...).
* Each task has the previous one as parent.
*/
async createPipeline(tasks) {
const taskIds = [];
for (let i = 0; i < tasks.length; i++) {
const taskOpts = { ...tasks[i] };
if (i > 0 && taskIds[i - 1]) {
taskOpts.parents = [...(taskOpts.parents || []), taskIds[i - 1]];
}
const { taskId } = await this.createTask(taskOpts);
taskIds.push(taskId);
}
return taskIds;
}
/**
* Get the status of a task.
*/
async getStatus(taskId) {
const { stdout } = await runKanban([...this.boardArgs, 'show', taskId, '--json']);
const parsed = JSON.parse(stdout.trim());
// Hermes wraps the task in a "task" key
const t = parsed.task || parsed;
return {
id: t.id || taskId,
title: t.title || '',
assignee: t.assignee || '',
status: t.status || 'unknown',
body: t.body,
tenant: t.tenant,
workspace: t.workspace_kind || t.workspace,
};
}
/**
* Wait for a task to complete (polling loop).
* YOLO: auto-reclaim on stale claims.
*/
async wait(taskId, timeoutMs = 600000) {
const startTime = Date.now();
const pollInterval = 5000; // 5s poll
let lastReclaimTime = 0;
while (Date.now() - startTime < timeoutMs) {
try {
const status = await this.getStatus(taskId);
// ─── Terminal states ─────────────────────────────────────────────
if (status.status === 'done') {
const { stdout } = await runKanban([...this.boardArgs, 'show', taskId, '--json']);
const parsed = JSON.parse(stdout.trim());
const t = parsed.task || parsed;
const runs = parsed.runs || t.runs || [];
const lastRun = runs[runs.length - 1];
return {
id: taskId,
status: 'done',
summary: lastRun?.summary || '',
metadata: lastRun?.metadata,
durationMs: lastRun?.started_at && lastRun?.ended_at
? lastRun.ended_at - lastRun.started_at
: undefined,
};
}
if (status.status === 'archived') {
return {
id: taskId,
status: 'archived',
error: 'Task was archived',
};
}
// ─── Blocked → YOLO auto-unblock ─────────────────────────────────
if (status.status === 'blocked' && YOLO_CONFIG.autoUnblock) {
logger.warn({ taskId }, '[WAIT] Task is blocked — YOLO auto-unblock.');
try {
await runKanban([...this.boardArgs, 'unblock', taskId]);
logger.info({ taskId }, '[WAIT] Task auto-unblocked.');
}
catch (e) {
logger.error({ taskId, error: e }, '[WAIT] Auto-unblock failed.');
}
}
// ─── Running → check for stale claim ─────────────────────────────
if (status.status === 'running' && YOLO_CONFIG.autoReclaim) {
const now = Date.now();
if (now - lastReclaimTime > YOLO_CONFIG.reclaimStaleMs) {
// Check if the claim is stale (no heartbeat for 15min)
const { stdout } = await runKanban([...this.boardArgs, 'show', taskId, '--json']);
const parsed = JSON.parse(stdout.trim());
const runs = parsed.runs || [];
const activeRun = runs.find((r) => r.outcome === 'active');
if (activeRun && activeRun.started_at) {
const claimAge = now - activeRun.started_at * 1000;
if (claimAge > YOLO_CONFIG.reclaimStaleMs) {
logger.warn({ taskId, claimAgeMs: claimAge }, '[WAIT] Stale claim detected — auto-reclaim.');
try {
await runKanban([...this.boardArgs, 'reclaim', taskId]);
logger.info({ taskId }, '[WAIT] Task reclaimed.');
}
catch (e) {
logger.error({ taskId, error: e }, '[WAIT] Auto-reclaim failed.');
}
lastReclaimTime = now;
}
}
}
}
}
catch (e) {
logger.warn({ taskId, error: e }, '[WAIT] Status check failed — will retry.');
}
// Sleep before next poll
await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
// Timeout reached
return {
id: taskId,
status: 'timeout',
error: `Task did not complete within ${timeoutMs}ms`,
};
}
/**
* Reclaim a task (abort running worker, reset to ready).
*/
async reclaim(taskId) {
await runKanban([...this.boardArgs, 'reclaim', taskId]);
logger.info({ taskId }, '[RECLAIM] Task reclaimed.');
}
/**
* Unblock a blocked task.
*/
async unblock(taskId) {
await runKanban([...this.boardArgs, 'unblock', taskId]);
logger.info({ taskId }, '[UNBLOCK] Task unblocked.');
}
/**
* Archive a task.
*/
async archive(taskId) {
await runKanban([...this.boardArgs, 'archive', taskId]);
logger.info({ taskId }, '[ARCHIVE] Task archived.');
}
/**
* List tasks with optional filter.
*/
async listTasks(filter) {
const args = [...this.boardArgs, 'list', '--json'];
const { stdout } = await runKanban(args);
let tasks;
try {
const parsed = JSON.parse(stdout.trim());
tasks = Array.isArray(parsed) ? parsed : parsed.tasks || [];
}
catch {
// Fallback: parse text output
logger.warn('[LIST] Failed to parse JSON output — returning empty list.');
return [];
}
// Apply filters
if (filter?.status) {
tasks = tasks.filter((t) => t.status === filter.status);
}
if (filter?.assignee) {
tasks = tasks.filter((t) => t.assignee === filter.assignee);
}
if (filter?.tenant) {
tasks = tasks.filter((t) => t.tenant === filter.tenant);
}
return tasks;
}
/**
* Add a comment to a task (inter-agent protocol).
*/
async comment(taskId, body) {
const args = [...this.boardArgs, 'comment', taskId, '--body', `"${body.replace(/"/g, '\\"')}"`];
await runKanban(args);
}
/**
* Create a board for a specific project/workstream.
*/
async createBoard(slug, name, opts) {
const args = ['boards', 'create', slug, '--name', `"${name}"`];
if (opts?.description)
args.push('--description', `"${opts.description}"`);
if (opts?.switch)
args.push('--switch');
await runKanban(args);
logger.info({ slug, name }, '[CREATE_BOARD] Board created.');
}
}
//# sourceMappingURL=KanbanAdapter.js.map