aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
298 lines (260 loc) • 7.97 kB
JavaScript
/**
* Agent Supervisor - Task queue and process management for claude -p spawning
*
* Manages a pool of concurrent agent subprocesses, tracking their lifecycle,
* streaming output, and reporting results through the event system.
*
* @implements @.aiwg/requirements/use-cases/UC-AGENT-001.md
* @tests @test/unit/daemon/agent-supervisor.test.js
*/
import { spawn } from 'node:child_process';
import { EventEmitter } from 'node:events';
export class AgentSupervisor extends EventEmitter {
constructor(options = {}) {
super();
this.maxConcurrency = options.maxConcurrency ?? 3;
this.taskStore = options.taskStore || null;
this.queue = [];
this.running = new Map(); // taskId -> { process, task }
this.shutdownInProgress = false;
this.agentCommand = options.agentCommand || 'claude';
this.agentArgs = options.agentArgs || [];
this.taskTimeout = options.taskTimeout || 120 * 60 * 1000; // 2 hours default
}
/**
* Submit a task to the queue
* Returns the task object with assigned ID
*/
submit(prompt, options = {}) {
if (this.shutdownInProgress) {
throw new Error('Supervisor is shutting down');
}
// Create task in store if available
const task = this.taskStore
? this.taskStore.createTask({ prompt, agent: options.agent, priority: options.priority || 0, metadata: options.metadata })
: {
id: `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
prompt,
agent: options.agent || null,
priority: options.priority || 0,
state: 'queued',
createdAt: new Date().toISOString(),
};
this.queue.push(task);
this.queue.sort((a, b) => (b.priority || 0) - (a.priority || 0));
this.emit('task:queued', { taskId: task.id, prompt: prompt.slice(0, 80), queueSize: this.queue.length });
this._processQueue();
return task;
}
/**
* Cancel a task (queued or running)
*/
cancel(taskId) {
// Check queue first
const queueIdx = this.queue.findIndex(t => t.id === taskId);
if (queueIdx !== -1) {
this.queue.splice(queueIdx, 1);
if (this.taskStore) {
this.taskStore.cancelTask(taskId);
}
this.emit('task:cancelled', { taskId });
return true;
}
// Check running
const entry = this.running.get(taskId);
if (entry) {
try {
entry.process.kill('SIGTERM');
} catch {
// Process may have already exited
}
// Cleanup happens in exit handler
return true;
}
return false;
}
/**
* Get status of all tasks
*/
getStatus() {
return {
running: this.running.size,
queued: this.queue.length,
maxConcurrency: this.maxConcurrency,
tasks: {
running: Array.from(this.running.entries()).map(([id, entry]) => ({
id,
prompt: entry.task.prompt.slice(0, 80),
agent: entry.task.agent,
startedAt: entry.task.startedAt,
pid: entry.process.pid,
})),
queued: this.queue.map(t => ({
id: t.id,
prompt: t.prompt.slice(0, 80),
agent: t.agent,
priority: t.priority,
})),
},
};
}
/**
* Graceful shutdown - drain queue and wait for running tasks
*/
async shutdown(timeoutMs = 30000) {
this.shutdownInProgress = true;
// Reject queued tasks
while (this.queue.length > 0) {
const task = this.queue.shift();
if (this.taskStore) {
this.taskStore.cancelTask(task.id);
}
this.emit('task:cancelled', { taskId: task.id, reason: 'shutdown' });
}
// Wait for running tasks to complete or timeout
if (this.running.size === 0) {
this.shutdownInProgress = false;
return;
}
const waitForCompletion = new Promise((resolve) => {
const checkInterval = setInterval(() => {
if (this.running.size === 0) {
clearInterval(checkInterval);
resolve();
}
}, 100);
});
const timeout = new Promise((resolve) => {
setTimeout(() => {
// Force kill remaining
for (const [taskId, entry] of this.running) {
try {
entry.process.kill('SIGKILL');
} catch {
// Already dead
}
if (this.taskStore) {
this.taskStore.failTask(taskId, 'Killed during shutdown');
}
}
this.running.clear();
resolve();
}, timeoutMs);
});
await Promise.race([waitForCompletion, timeout]);
this.shutdownInProgress = false;
}
/**
* Get running task count
*/
get runningCount() {
return this.running.size;
}
/**
* Get queued task count
*/
get queuedCount() {
return this.queue.length;
}
// --- Private methods ---
_processQueue() {
while (
this.running.size < this.maxConcurrency &&
this.queue.length > 0 &&
!this.shutdownInProgress
) {
const task = this.queue.shift();
this._runTask(task);
}
}
_runTask(task) {
const args = [...this.agentArgs, '-p', task.prompt];
if (task.agent) {
args.push('--agent', task.agent);
}
const proc = spawn(this.agentCommand, args, {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env },
});
// Track start time
task.startedAt = new Date().toISOString();
task.state = 'running';
if (this.taskStore) {
this.taskStore.startTask(task.id, proc.pid);
}
let stdout = '';
let stderr = '';
proc.stdout.on('data', (chunk) => {
const text = chunk.toString();
stdout += text;
if (this.taskStore) {
this.taskStore.appendOutput(task.id, text);
}
this.emit('task:output', { taskId: task.id, chunk: text, stream: 'stdout' });
});
proc.stderr.on('data', (chunk) => {
const text = chunk.toString();
stderr += text;
this.emit('task:output', { taskId: task.id, chunk: text, stream: 'stderr' });
});
// Timeout handler
const timeoutTimer = setTimeout(() => {
try {
proc.kill('SIGTERM');
} catch {
// Already dead
}
if (this.taskStore) {
this.taskStore.failTask(task.id, `Task timed out after ${this.taskTimeout}ms`);
}
this.running.delete(task.id);
this.emit('task:timeout', { taskId: task.id });
this._processQueue();
}, this.taskTimeout);
proc.on('exit', (code, signal) => {
clearTimeout(timeoutTimer);
this.running.delete(task.id);
if (signal === 'SIGTERM' || signal === 'SIGKILL') {
// Cancelled or killed
if (this.taskStore && this.taskStore.getTask(task.id)?.state === 'running') {
this.taskStore.cancelTask(task.id);
}
this.emit('task:cancelled', { taskId: task.id, signal });
} else if (code === 0) {
if (this.taskStore) {
this.taskStore.completeTask(task.id, stdout);
}
this.emit('task:completed', {
taskId: task.id,
result: stdout,
duration: Date.now() - new Date(task.startedAt).getTime(),
});
} else {
const errorMsg = stderr || `Process exited with code ${code}`;
if (this.taskStore) {
this.taskStore.failTask(task.id, errorMsg);
}
this.emit('task:failed', {
taskId: task.id,
error: errorMsg,
exitCode: code,
});
}
this._processQueue();
});
proc.on('error', (err) => {
clearTimeout(timeoutTimer);
this.running.delete(task.id);
if (this.taskStore) {
this.taskStore.failTask(task.id, err.message);
}
this.emit('task:failed', {
taskId: task.id,
error: err.message,
});
this._processQueue();
});
this.running.set(task.id, { process: proc, task });
this.emit('task:started', { taskId: task.id, pid: proc.pid });
}
}