feat: add Orchestrator Loop — phased plan execution with team agents

Adds a new autonomous loop that accepts high-level goals, generates
phased execution plans via AI, and executes them step-by-step with
verification gates between phases.

Core components:
- OrchestratorLoop: state machine (idle→planning→approval→executing→verifying→completed)
- OrchestratorPlanner: plan generation via PlanOrchestrator, Kahn's algorithm phase grouping
- OrchestratorVerifier: phase verification (strict/moderate/lenient modes)
- Prompt templates for phase execution, team delegation, verification, replanning

API (10 endpoints):
- POST start/approve/reject/pause/resume/stop
- GET status/plan
- POST phase/:id/skip, phase/:id/retry

Frontend: orchestrator-panel.js with SSE-driven state, phase progress, task tracking

Tests: 22 tests (18 route + 4 unit), all passing. Typecheck/lint/format clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-03-21 07:20:18 +01:00
co-authored by Claude Opus 4.6
parent 497ca4891a
commit 61b5ec095c
27 changed files with 4718 additions and 1 deletions
+1
View File
@@ -14,4 +14,5 @@ export { registerSessionRoutes } from './session-routes.js';
export { registerRespawnRoutes } from './respawn-routes.js';
export { registerRalphRoutes } from './ralph-routes.js';
export { registerPlanRoutes } from './plan-routes.js';
export { registerOrchestratorRoutes } from './orchestrator-routes.js';
export { registerWsRoutes } from './ws-routes.js';
+250
View File
@@ -0,0 +1,250 @@
/**
* @fileoverview Orchestrator loop routes — plan-based autonomous execution.
*
* Endpoints:
* - POST /api/orchestrator/start — Start orchestration with a goal
* - POST /api/orchestrator/approve — Approve generated plan
* - POST /api/orchestrator/reject — Reject plan with feedback
* - POST /api/orchestrator/pause — Pause execution
* - POST /api/orchestrator/resume — Resume from pause
* - POST /api/orchestrator/stop — Stop and clean up
* - GET /api/orchestrator/status — Get current status
* - GET /api/orchestrator/plan — Get current plan
* - POST /api/orchestrator/phase/:id/skip — Skip a phase
* - POST /api/orchestrator/phase/:id/retry — Retry a failed phase
*
* @module web/routes/orchestrator-routes
*/
import { FastifyInstance } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import { OrchestratorStartSchema, OrchestratorRejectSchema } from '../schemas.js';
import { SseEvent } from '../sse-events.js';
import type { EventPort, OrchestratorPort } from '../ports/index.js';
export function registerOrchestratorRoutes(app: FastifyInstance, ctx: OrchestratorPort & EventPort): void {
// ═══════════════════════════════════════════════════════════════
// Helpers
// ═══════════════════════════════════════════════════════════════
function getLoop() {
const loop = ctx.orchestratorLoop;
if (!loop) {
throw Object.assign(new Error('Orchestrator not initialized'), {
statusCode: 503,
body: createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'Orchestrator not initialized'),
});
}
return loop;
}
function setupEventForwarding(loop: import('../../orchestrator-loop.js').OrchestratorLoop) {
loop.on('stateChanged', (state, prevState) => {
ctx.broadcast(SseEvent.OrchestratorStateChanged, { state, prevState });
});
loop.on('planReady', (plan) => {
ctx.broadcast(SseEvent.OrchestratorPlanReady, { plan });
});
loop.on('phaseStarted', (phase) => {
ctx.broadcast(SseEvent.OrchestratorPhaseStarted, { phase });
});
loop.on('phaseCompleted', (phase) => {
ctx.broadcast(SseEvent.OrchestratorPhaseCompleted, { phase });
});
loop.on('phaseFailed', (phase, reason) => {
ctx.broadcast(SseEvent.OrchestratorPhaseFailed, { phase, reason });
});
loop.on('verificationResult', (phase, result) => {
ctx.broadcast(SseEvent.OrchestratorVerification, { phaseId: phase.id, result });
});
loop.on('taskAssigned', (task, sessionId) => {
ctx.broadcast(SseEvent.OrchestratorTaskAssigned, { task, sessionId });
});
loop.on('taskCompleted', (task) => {
ctx.broadcast(SseEvent.OrchestratorTaskCompleted, { task });
});
loop.on('taskFailed', (task, error) => {
ctx.broadcast(SseEvent.OrchestratorTaskFailed, { task, error });
});
loop.on('completed', (stats) => {
ctx.broadcast(SseEvent.OrchestratorCompleted, { stats });
});
loop.on('error', (error) => {
ctx.broadcast(SseEvent.OrchestratorError, { error: error.message });
});
}
// ═══════════════════════════════════════════════════════════════
// Start
// ═══════════════════════════════════════════════════════════════
app.post('/api/orchestrator/start', async (req) => {
const parsed = OrchestratorStartSchema.safeParse(req.body);
if (!parsed.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const { goal, config } = parsed.data;
// Initialize loop if needed
let loop = ctx.orchestratorLoop;
if (!loop) {
loop = ctx.initOrchestratorLoop();
setupEventForwarding(loop);
}
// Check if already running
if (loop.isRunning()) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Orchestrator is already running');
}
try {
// Start is async — kicks off planning
loop.start(goal).catch((err) => {
console.error('[Orchestrator Route] Start failed:', getErrorMessage(err));
});
return {
ok: true,
state: loop.state,
message: 'Orchestrator started — generating plan',
config: config ?? null,
};
} catch (err) {
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, getErrorMessage(err));
}
});
// ═══════════════════════════════════════════════════════════════
// Approve / Reject Plan
// ═══════════════════════════════════════════════════════════════
app.post('/api/orchestrator/approve', async () => {
const loop = getLoop();
try {
loop.approve().catch((err) => {
console.error('[Orchestrator Route] Approve failed:', getErrorMessage(err));
});
return { ok: true, state: loop.state };
} catch (err) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
}
});
app.post('/api/orchestrator/reject', async (req) => {
const loop = getLoop();
const parsed = OrchestratorRejectSchema.safeParse(req.body);
if (!parsed.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Feedback is required');
}
try {
loop.reject(parsed.data.feedback).catch((err) => {
console.error('[Orchestrator Route] Reject failed:', getErrorMessage(err));
});
return { ok: true, state: loop.state };
} catch (err) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
}
});
// ═══════════════════════════════════════════════════════════════
// Pause / Resume / Stop
// ═══════════════════════════════════════════════════════════════
app.post('/api/orchestrator/pause', async () => {
const loop = getLoop();
try {
loop.pause();
return { ok: true, state: loop.state };
} catch (err) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
}
});
app.post('/api/orchestrator/resume', async () => {
const loop = getLoop();
try {
loop.resume().catch((err) => {
console.error('[Orchestrator Route] Resume failed:', getErrorMessage(err));
});
return { ok: true, state: loop.state };
} catch (err) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
}
});
app.post('/api/orchestrator/stop', async () => {
const loop = getLoop();
try {
await loop.stop();
return { ok: true, state: loop.state };
} catch (err) {
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, getErrorMessage(err));
}
});
// ═══════════════════════════════════════════════════════════════
// Status / Plan
// ═══════════════════════════════════════════════════════════════
app.get('/api/orchestrator/status', async () => {
const loop = ctx.orchestratorLoop;
if (!loop) {
return { ok: true, state: 'idle', plan: null, stats: null };
}
return {
ok: true,
...loop.getStatus(),
};
});
app.get('/api/orchestrator/plan', async () => {
const loop = ctx.orchestratorLoop;
if (!loop) {
return { ok: true, plan: null };
}
return {
ok: true,
plan: loop.getPlan(),
currentPhase: loop.getCurrentPhase(),
};
});
// ═══════════════════════════════════════════════════════════════
// Phase Operations
// ═══════════════════════════════════════════════════════════════
app.post('/api/orchestrator/phase/:id/skip', async (req) => {
const loop = getLoop();
const { id } = req.params as { id: string };
try {
await loop.skipPhase(id);
return { ok: true, state: loop.state };
} catch (err) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
}
});
app.post('/api/orchestrator/phase/:id/retry', async (req) => {
const loop = getLoop();
const { id } = req.params as { id: string };
try {
loop.retryPhase(id).catch((err) => {
console.error('[Orchestrator Route] Retry failed:', getErrorMessage(err));
});
return { ok: true, state: loop.state };
} catch (err) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
}
});
}