mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 23:19:43 +02:00
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:
@@ -0,0 +1,912 @@
|
||||
/**
|
||||
* @fileoverview Orchestrator Loop — phased plan execution with team agents.
|
||||
*
|
||||
* State machine that generates plans from user goals, executes them
|
||||
* phase-by-phase with verification gates, and adapts on failure.
|
||||
*
|
||||
* States: idle → planning → approval → executing → verifying → (replanning) → completed/failed
|
||||
*
|
||||
* Key exports:
|
||||
* - `OrchestratorLoop` class — main engine, extends EventEmitter
|
||||
* - `OrchestratorLoopEvents` interface — typed event map
|
||||
*
|
||||
* Lifecycle: `start(goal)` → plan → approve → execute phases → verify → complete
|
||||
*
|
||||
* @dependencies orchestrator-planner (plan generation), orchestrator-verifier (phase verification),
|
||||
* session-manager (sessions), task-queue (task execution), state-store (persistence),
|
||||
* prompts/orchestrator (prompt templates)
|
||||
* @consumedby web/server (orchestrator routes, SSE)
|
||||
* @emits stateChanged, planReady, phaseStarted, phaseCompleted, phaseFailed,
|
||||
* taskAssigned, taskCompleted, taskFailed, verificationResult, completed, error
|
||||
* @persistence Orchestrator state saved to `~/.codeman/state.json` (orchestrator key)
|
||||
*
|
||||
* @module orchestrator-loop
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { getSessionManager, SessionManager } from './session-manager.js';
|
||||
import { getTaskQueue, TaskQueue } from './task-queue.js';
|
||||
import { getStore, StateStore } from './state-store.js';
|
||||
import { OrchestratorPlanner } from './orchestrator-planner.js';
|
||||
import { OrchestratorVerifier } from './orchestrator-verifier.js';
|
||||
import { PHASE_EXECUTION_PROMPT, REPLAN_PROMPT, SINGLE_TASK_PROMPT, TEAM_LEAD_PROMPT } from './prompts/index.js';
|
||||
import type { TerminalMultiplexer } from './mux-interface.js';
|
||||
import type { CreateTaskOptions } from './task.js';
|
||||
import {
|
||||
type OrchestratorState,
|
||||
type OrchestratorPlan,
|
||||
type OrchestratorPhase,
|
||||
type OrchestratorTask,
|
||||
type OrchestratorConfig,
|
||||
type OrchestratorStats,
|
||||
type OrchestratorPersistState,
|
||||
type VerificationResult,
|
||||
DEFAULT_ORCHESTRATOR_CONFIG,
|
||||
createInitialOrchestratorStats,
|
||||
getErrorMessage,
|
||||
} from './types.js';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Constants
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Poll interval for checking task completion within a phase (2 seconds) */
|
||||
const PHASE_POLL_INTERVAL_MS = 2000;
|
||||
|
||||
/** Delay between phase completion and verification (1 second) */
|
||||
const POST_PHASE_DELAY_MS = 1000;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Events
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export interface OrchestratorLoopEvents {
|
||||
stateChanged: (state: OrchestratorState, prevState: OrchestratorState) => void;
|
||||
planReady: (plan: OrchestratorPlan) => void;
|
||||
phaseStarted: (phase: OrchestratorPhase) => void;
|
||||
phaseCompleted: (phase: OrchestratorPhase) => void;
|
||||
phaseFailed: (phase: OrchestratorPhase, reason: string) => void;
|
||||
taskAssigned: (task: OrchestratorTask, sessionId: string) => void;
|
||||
taskCompleted: (task: OrchestratorTask) => void;
|
||||
taskFailed: (task: OrchestratorTask, error: string) => void;
|
||||
verificationResult: (phase: OrchestratorPhase, result: VerificationResult) => void;
|
||||
completed: (stats: OrchestratorStats) => void;
|
||||
error: (error: Error) => void;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// OrchestratorLoop
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export class OrchestratorLoop extends EventEmitter {
|
||||
private _state: OrchestratorState = 'idle';
|
||||
private plan: OrchestratorPlan | null = null;
|
||||
private currentPhaseIndex = 0;
|
||||
private config: OrchestratorConfig;
|
||||
private stats: OrchestratorStats;
|
||||
private startedAt: number | null = null;
|
||||
private completedAt: number | null = null;
|
||||
|
||||
private workingDir: string;
|
||||
private planner: OrchestratorPlanner;
|
||||
private verifier: OrchestratorVerifier;
|
||||
private sessionManager: SessionManager;
|
||||
private taskQueue: TaskQueue;
|
||||
private store: StateStore;
|
||||
|
||||
/** State before pause (to resume to correct state) */
|
||||
private pausedState: OrchestratorState | null = null;
|
||||
|
||||
/** Phase poll timer for checking task completion */
|
||||
private phasePollTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
/** Session completion listener (bound for cleanup) */
|
||||
private sessionCompletionListener: ((sessionId: string, phrase: string) => void) | null = null;
|
||||
|
||||
/** Active sessions assigned to current phase */
|
||||
private phaseSessionIds: Set<string> = new Set();
|
||||
|
||||
constructor(mux: TerminalMultiplexer, workingDir: string, config?: Partial<OrchestratorConfig>) {
|
||||
super();
|
||||
this.workingDir = workingDir;
|
||||
this.config = { ...DEFAULT_ORCHESTRATOR_CONFIG, ...config };
|
||||
this.stats = createInitialOrchestratorStats();
|
||||
this.sessionManager = getSessionManager();
|
||||
this.taskQueue = getTaskQueue();
|
||||
this.store = getStore();
|
||||
this.planner = new OrchestratorPlanner(mux, workingDir, this.config);
|
||||
this.verifier = new OrchestratorVerifier(this.config);
|
||||
|
||||
// Restore state if crashed while running
|
||||
this.restore();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Public API — Lifecycle
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Start orchestration with a goal. Transitions: idle → planning */
|
||||
async start(goal: string): Promise<void> {
|
||||
if (this._state !== 'idle' && this._state !== 'failed' && this._state !== 'completed') {
|
||||
throw new Error(`Cannot start from state "${this._state}"`);
|
||||
}
|
||||
|
||||
this.reset();
|
||||
this.startedAt = Date.now();
|
||||
this.setState('planning');
|
||||
|
||||
try {
|
||||
const plan = await this.planner.generatePlan(goal, (phase, detail) => {
|
||||
// Forward planning progress — could emit an event here
|
||||
console.log(`[Orchestrator] Planning: ${phase} — ${detail}`);
|
||||
});
|
||||
|
||||
if ((this._state as OrchestratorState) !== 'planning') {
|
||||
// Cancelled during planning
|
||||
return;
|
||||
}
|
||||
|
||||
this.plan = plan;
|
||||
this.persist();
|
||||
|
||||
if (this.config.autoApprove) {
|
||||
this.setState('executing');
|
||||
await this.executeCurrentPhase();
|
||||
} else {
|
||||
this.setState('approval');
|
||||
this.emit('planReady', plan);
|
||||
}
|
||||
} catch (err) {
|
||||
this.handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Approve the generated plan. Transitions: approval → executing */
|
||||
async approve(): Promise<void> {
|
||||
if ((this._state as OrchestratorState) !== 'approval') {
|
||||
throw new Error(`Cannot approve from state "${this._state}"`);
|
||||
}
|
||||
if (!this.plan) {
|
||||
throw new Error('No plan to approve');
|
||||
}
|
||||
|
||||
this.setState('executing');
|
||||
await this.executeCurrentPhase();
|
||||
}
|
||||
|
||||
/** Reject plan with feedback. Transitions: approval → planning (regenerate) */
|
||||
async reject(feedback: string): Promise<void> {
|
||||
if ((this._state as OrchestratorState) !== 'approval') {
|
||||
throw new Error(`Cannot reject from state "${this._state}"`);
|
||||
}
|
||||
if (!this.plan) {
|
||||
throw new Error('No plan to reject');
|
||||
}
|
||||
|
||||
const goal = this.plan.goal + '\n\nFeedback on previous plan: ' + feedback;
|
||||
this.plan = null;
|
||||
this.setState('planning');
|
||||
|
||||
try {
|
||||
const plan = await this.planner.generatePlan(goal);
|
||||
|
||||
if ((this._state as OrchestratorState) !== 'planning') return;
|
||||
|
||||
this.plan = plan;
|
||||
this.persist();
|
||||
this.setState('approval');
|
||||
this.emit('planReady', plan);
|
||||
} catch (err) {
|
||||
this.handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Pause execution. Saves current state. */
|
||||
pause(): void {
|
||||
if (this._state === 'idle' || this._state === 'paused' || this._state === 'completed' || this._state === 'failed') {
|
||||
return;
|
||||
}
|
||||
this.pausedState = this._state;
|
||||
this.clearPhasePoll();
|
||||
this.setState('paused');
|
||||
}
|
||||
|
||||
/** Resume from pause. */
|
||||
async resume(): Promise<void> {
|
||||
if (this._state !== 'paused' || !this.pausedState) {
|
||||
throw new Error('Not paused');
|
||||
}
|
||||
|
||||
const resumeTo = this.pausedState;
|
||||
this.pausedState = null;
|
||||
this.setState(resumeTo);
|
||||
|
||||
// Re-enter the appropriate phase of execution
|
||||
if (resumeTo === 'executing') {
|
||||
await this.executeCurrentPhase();
|
||||
} else if (resumeTo === 'verifying') {
|
||||
await this.verifyCurrentPhase();
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop everything and clean up. */
|
||||
async stop(): Promise<void> {
|
||||
this.clearPhasePoll();
|
||||
this.cleanupTaskHandlers();
|
||||
await this.planner.cancel();
|
||||
this.setState('idle');
|
||||
this.store.clearOrchestratorState();
|
||||
}
|
||||
|
||||
/** Skip a specific phase. */
|
||||
async skipPhase(phaseId: string): Promise<void> {
|
||||
if (!this.plan) return;
|
||||
|
||||
const phase = this.plan.phases.find((p) => p.id === phaseId);
|
||||
if (!phase) throw new Error(`Phase "${phaseId}" not found`);
|
||||
|
||||
phase.status = 'skipped';
|
||||
phase.completedAt = Date.now();
|
||||
this.persist();
|
||||
|
||||
// If this is the current phase, advance
|
||||
if (this.plan.phases[this.currentPhaseIndex]?.id === phaseId) {
|
||||
await this.advanceToNextPhase();
|
||||
}
|
||||
}
|
||||
|
||||
/** Retry a failed phase. */
|
||||
async retryPhase(phaseId: string): Promise<void> {
|
||||
if (!this.plan) return;
|
||||
if (this._state !== 'executing' && this._state !== 'failed') {
|
||||
throw new Error(`Cannot retry from state "${this._state}"`);
|
||||
}
|
||||
|
||||
const phaseIndex = this.plan.phases.findIndex((p) => p.id === phaseId);
|
||||
if (phaseIndex === -1) throw new Error(`Phase "${phaseId}" not found`);
|
||||
|
||||
const phase = this.plan.phases[phaseIndex];
|
||||
phase.status = 'pending';
|
||||
phase.attempts = 0;
|
||||
for (const task of phase.tasks) {
|
||||
task.status = 'pending';
|
||||
task.error = null;
|
||||
task.assignedSessionId = null;
|
||||
task.queueTaskId = null;
|
||||
}
|
||||
|
||||
this.currentPhaseIndex = phaseIndex;
|
||||
this.setState('executing');
|
||||
await this.executeCurrentPhase();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Public API — Getters
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
get state(): OrchestratorState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
getPlan(): OrchestratorPlan | null {
|
||||
return this.plan;
|
||||
}
|
||||
|
||||
getCurrentPhase(): OrchestratorPhase | null {
|
||||
if (!this.plan) return null;
|
||||
return this.plan.phases[this.currentPhaseIndex] ?? null;
|
||||
}
|
||||
|
||||
getStats(): OrchestratorStats {
|
||||
return { ...this.stats };
|
||||
}
|
||||
|
||||
getStatus(): OrchestratorPersistState {
|
||||
return {
|
||||
state: this._state,
|
||||
plan: this.plan,
|
||||
currentPhaseIndex: this.currentPhaseIndex,
|
||||
startedAt: this.startedAt,
|
||||
completedAt: this.completedAt,
|
||||
config: this.config,
|
||||
stats: this.stats,
|
||||
};
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this._state !== 'idle' && this._state !== 'completed' && this._state !== 'failed';
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Internal — Phase Execution
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private async executeCurrentPhase(): Promise<void> {
|
||||
if (!this.plan || this._state !== 'executing') return;
|
||||
|
||||
const phase = this.plan.phases[this.currentPhaseIndex];
|
||||
if (!phase) {
|
||||
// All phases done
|
||||
await this.handleCompletion();
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip already completed/skipped phases
|
||||
if (phase.status === 'passed' || phase.status === 'skipped') {
|
||||
await this.advanceToNextPhase();
|
||||
return;
|
||||
}
|
||||
|
||||
phase.status = 'executing';
|
||||
phase.startedAt = Date.now();
|
||||
phase.attempts++;
|
||||
this.persist();
|
||||
this.emit('phaseStarted', phase);
|
||||
|
||||
try {
|
||||
await this.assignPhaseTasks(phase);
|
||||
this.startPhasePoll(phase);
|
||||
} catch (err) {
|
||||
this.handlePhaseError(phase, getErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
private async assignPhaseTasks(phase: OrchestratorPhase): Promise<void> {
|
||||
// For team strategy, send a single comprehensive prompt to a lead session
|
||||
if (phase.teamStrategy.type === 'team') {
|
||||
await this.assignTeamPhase(phase);
|
||||
return;
|
||||
}
|
||||
|
||||
// For single/parallel strategy, add individual tasks to TaskQueue
|
||||
for (const task of phase.tasks) {
|
||||
if (task.status !== 'pending') continue;
|
||||
|
||||
const prompt = this.buildTaskPrompt(task, phase);
|
||||
const taskOptions: CreateTaskOptions = {
|
||||
prompt,
|
||||
workingDir: this.workingDir,
|
||||
priority: 100 - phase.order, // Earlier phases get higher priority
|
||||
completionPhrase: task.completionPhrase,
|
||||
timeoutMs: Math.min(task.timeoutMs, this.config.phaseTimeoutMs),
|
||||
};
|
||||
|
||||
const queueTask = this.taskQueue.addTask(taskOptions);
|
||||
task.queueTaskId = queueTask.id;
|
||||
task.status = 'running';
|
||||
}
|
||||
|
||||
this.persist();
|
||||
this.setupTaskHandlers();
|
||||
|
||||
// Manually assign tasks to idle sessions
|
||||
await this.assignQueuedTasksToSessions();
|
||||
}
|
||||
|
||||
private async assignTeamPhase(phase: OrchestratorPhase): Promise<void> {
|
||||
const teamConfig = phase.teamStrategy.type === 'team' ? phase.teamStrategy.config : null;
|
||||
if (!teamConfig) return;
|
||||
|
||||
// Find or use an idle session
|
||||
const sessions = this.sessionManager.getIdleSessions();
|
||||
if (sessions.length === 0) {
|
||||
throw new Error('No idle sessions available for team phase execution');
|
||||
}
|
||||
|
||||
const session = sessions[0];
|
||||
this.phaseSessionIds.add(session.id);
|
||||
|
||||
// Mark all tasks as running under this session
|
||||
for (const task of phase.tasks) {
|
||||
task.status = 'running';
|
||||
task.assignedSessionId = session.id;
|
||||
}
|
||||
|
||||
// Build and send the team lead prompt
|
||||
const prompt = TEAM_LEAD_PROMPT.replace('{PHASE_NAME}', phase.name)
|
||||
.replace('{TASK_LIST}', phase.tasks.map((t, i) => `${i + 1}. ${t.prompt}`).join('\n'))
|
||||
.replace('{TEAMMATE_HINTS}', teamConfig.suggestedTeammates.map((h, i) => `${i + 1}. ${h}`).join('\n'))
|
||||
.replace('{COMPLETION_PHRASE}', `${phase.id.toUpperCase()}_COMPLETE`);
|
||||
|
||||
// Create a TaskQueue task for the entire phase
|
||||
const queueTask = this.taskQueue.addTask({
|
||||
prompt,
|
||||
workingDir: this.workingDir,
|
||||
priority: 100 - phase.order,
|
||||
completionPhrase: `${phase.id.toUpperCase()}_COMPLETE`,
|
||||
timeoutMs: this.config.phaseTimeoutMs,
|
||||
});
|
||||
|
||||
// Link all phase tasks to this single queue task
|
||||
for (const task of phase.tasks) {
|
||||
task.queueTaskId = queueTask.id;
|
||||
}
|
||||
|
||||
this.persist();
|
||||
this.setupTaskHandlers();
|
||||
|
||||
// Assign the task to the session
|
||||
try {
|
||||
queueTask.assign(session.id);
|
||||
session.assignTask(queueTask.id);
|
||||
this.taskQueue.updateTask(queueTask);
|
||||
await session.sendInput(prompt);
|
||||
} catch (err) {
|
||||
queueTask.fail(getErrorMessage(err));
|
||||
this.taskQueue.updateTask(queueTask);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async assignQueuedTasksToSessions(): Promise<void> {
|
||||
const idleSessions = this.sessionManager.getIdleSessions();
|
||||
const maxSessions =
|
||||
this.getCurrentPhase()?.teamStrategy.type === 'parallel'
|
||||
? (this.getCurrentPhase()?.teamStrategy as { type: 'parallel'; maxSessions: number }).maxSessions
|
||||
: 1;
|
||||
|
||||
const sessionsToUse = idleSessions.slice(0, maxSessions);
|
||||
|
||||
for (const session of sessionsToUse) {
|
||||
const task = this.taskQueue.next();
|
||||
if (!task) break;
|
||||
|
||||
try {
|
||||
task.assign(session.id);
|
||||
session.assignTask(task.id);
|
||||
this.taskQueue.updateTask(task);
|
||||
await session.sendInput(task.prompt);
|
||||
|
||||
this.phaseSessionIds.add(session.id);
|
||||
|
||||
// Find the orchestrator task linked to this queue task
|
||||
const orchTask = this.findOrchestratorTaskByQueueId(task.id);
|
||||
if (orchTask) {
|
||||
orchTask.assignedSessionId = session.id;
|
||||
orchTask.startedAt = Date.now();
|
||||
this.emit('taskAssigned', orchTask, session.id);
|
||||
}
|
||||
} catch (err) {
|
||||
task.fail(getErrorMessage(err));
|
||||
session.clearTask();
|
||||
this.taskQueue.updateTask(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Internal — Task Completion Tracking
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private setupTaskHandlers(): void {
|
||||
this.cleanupTaskHandlers();
|
||||
|
||||
this.sessionCompletionListener = (_sessionId: string, _phrase: string) => {
|
||||
// Session completion — check if it's related to our phase tasks
|
||||
this.checkPhaseCompletion();
|
||||
};
|
||||
|
||||
this.sessionManager.on('sessionCompletion', this.sessionCompletionListener);
|
||||
}
|
||||
|
||||
private cleanupTaskHandlers(): void {
|
||||
if (this.sessionCompletionListener) {
|
||||
this.sessionManager.off('sessionCompletion', this.sessionCompletionListener);
|
||||
this.sessionCompletionListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleTaskCompleted(queueTaskId: string): void {
|
||||
const orchTask = this.findOrchestratorTaskByQueueId(queueTaskId);
|
||||
if (!orchTask) return;
|
||||
|
||||
orchTask.status = 'completed';
|
||||
orchTask.completedAt = Date.now();
|
||||
this.stats.totalTasksCompleted++;
|
||||
this.persist();
|
||||
this.emit('taskCompleted', orchTask);
|
||||
|
||||
this.checkPhaseCompletion();
|
||||
}
|
||||
|
||||
private handleTaskFailed(queueTaskId: string, error: string): void {
|
||||
const orchTask = this.findOrchestratorTaskByQueueId(queueTaskId);
|
||||
if (!orchTask) return;
|
||||
|
||||
orchTask.status = 'failed';
|
||||
orchTask.error = error;
|
||||
this.stats.totalTasksFailed++;
|
||||
this.persist();
|
||||
this.emit('taskFailed', orchTask, error);
|
||||
|
||||
// Check if we should retry the task or fail the phase
|
||||
if (orchTask.retries < 2) {
|
||||
orchTask.retries++;
|
||||
orchTask.status = 'pending';
|
||||
orchTask.error = null;
|
||||
orchTask.queueTaskId = null;
|
||||
// Will be re-queued on next poll
|
||||
} else {
|
||||
this.checkPhaseCompletion();
|
||||
}
|
||||
}
|
||||
|
||||
private startPhasePoll(phase: OrchestratorPhase): void {
|
||||
this.clearPhasePoll();
|
||||
this.phasePollTimer = setInterval(() => {
|
||||
if (this._state !== 'executing') {
|
||||
this.clearPhasePoll();
|
||||
return;
|
||||
}
|
||||
this.pollPhaseStatus(phase);
|
||||
}, PHASE_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private clearPhasePoll(): void {
|
||||
if (this.phasePollTimer) {
|
||||
clearInterval(this.phasePollTimer);
|
||||
this.phasePollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private pollPhaseStatus(phase: OrchestratorPhase): void {
|
||||
// Check for queued tasks that need assignment
|
||||
const pendingTasks = phase.tasks.filter((t) => t.status === 'pending' && !t.queueTaskId);
|
||||
if (pendingTasks.length > 0) {
|
||||
// Re-queue pending tasks
|
||||
for (const task of pendingTasks) {
|
||||
const prompt = this.buildTaskPrompt(task, phase);
|
||||
const queueTask = this.taskQueue.addTask({
|
||||
prompt,
|
||||
workingDir: this.workingDir,
|
||||
priority: 100 - phase.order,
|
||||
completionPhrase: task.completionPhrase,
|
||||
timeoutMs: Math.min(task.timeoutMs, this.config.phaseTimeoutMs),
|
||||
});
|
||||
task.queueTaskId = queueTask.id;
|
||||
task.status = 'running';
|
||||
}
|
||||
this.assignQueuedTasksToSessions().catch(() => {}); // Best effort
|
||||
}
|
||||
|
||||
// Check completion status of queue tasks
|
||||
for (const task of phase.tasks) {
|
||||
if (task.status === 'running' && task.queueTaskId) {
|
||||
const queueTask = this.taskQueue.getTask(task.queueTaskId);
|
||||
if (queueTask) {
|
||||
if (queueTask.isCompleted()) {
|
||||
this.handleTaskCompleted(task.queueTaskId);
|
||||
} else if (queueTask.isFailed()) {
|
||||
this.handleTaskFailed(task.queueTaskId, queueTask.error || 'Task failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.checkPhaseCompletion();
|
||||
}
|
||||
|
||||
private checkPhaseCompletion(): void {
|
||||
if (this._state !== 'executing') return;
|
||||
|
||||
const phase = this.getCurrentPhase();
|
||||
if (!phase) return;
|
||||
|
||||
const allDone = phase.tasks.every((t) => t.status === 'completed' || t.status === 'failed');
|
||||
if (!allDone) return;
|
||||
|
||||
const anyFailed = phase.tasks.some((t) => t.status === 'failed');
|
||||
|
||||
this.clearPhasePoll();
|
||||
|
||||
if (anyFailed) {
|
||||
// Phase has failed tasks
|
||||
this.handlePhaseError(phase, 'One or more tasks failed');
|
||||
} else {
|
||||
// All tasks completed — run verification
|
||||
setTimeout(() => {
|
||||
this.verifyCurrentPhase().catch((err) => this.handleError(err));
|
||||
}, POST_PHASE_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Internal — Verification
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private async verifyCurrentPhase(): Promise<void> {
|
||||
if (!this.plan) return;
|
||||
|
||||
const phase = this.plan.phases[this.currentPhaseIndex];
|
||||
if (!phase) return;
|
||||
|
||||
// Skip verification if no criteria defined
|
||||
if (phase.verificationCriteria.length === 0 && phase.testCommands.length === 0) {
|
||||
phase.status = 'passed';
|
||||
phase.completedAt = Date.now();
|
||||
phase.durationMs = phase.startedAt ? Date.now() - phase.startedAt : null;
|
||||
this.stats.phasesCompleted++;
|
||||
this.persist();
|
||||
this.emit('phaseCompleted', phase);
|
||||
await this.advanceToNextPhase();
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState('verifying');
|
||||
|
||||
// Get a session for verification
|
||||
const sessions = this.sessionManager.getIdleSessions();
|
||||
if (sessions.length === 0) {
|
||||
// No idle sessions — mark as passed (can't verify)
|
||||
phase.status = 'passed';
|
||||
phase.completedAt = Date.now();
|
||||
phase.durationMs = phase.startedAt ? Date.now() - phase.startedAt : null;
|
||||
this.stats.phasesCompleted++;
|
||||
this.persist();
|
||||
this.emit('phaseCompleted', phase);
|
||||
this.setState('executing');
|
||||
await this.advanceToNextPhase();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.verifier.verifyPhase(phase, sessions[0]);
|
||||
this.emit('verificationResult', phase, result);
|
||||
|
||||
if (result.passed) {
|
||||
phase.status = 'passed';
|
||||
phase.completedAt = Date.now();
|
||||
phase.durationMs = phase.startedAt ? Date.now() - phase.startedAt : null;
|
||||
this.stats.phasesCompleted++;
|
||||
this.persist();
|
||||
this.emit('phaseCompleted', phase);
|
||||
this.setState('executing');
|
||||
await this.advanceToNextPhase();
|
||||
} else {
|
||||
// Verification failed — attempt replan
|
||||
await this.handleVerificationFailure(phase, result);
|
||||
}
|
||||
} catch (err) {
|
||||
// Verification error — treat as pass (don't block on verification bugs)
|
||||
console.warn('[Orchestrator] Verification error, treating as pass:', err);
|
||||
phase.status = 'passed';
|
||||
phase.completedAt = Date.now();
|
||||
phase.durationMs = phase.startedAt ? Date.now() - phase.startedAt : null;
|
||||
this.stats.phasesCompleted++;
|
||||
this.persist();
|
||||
this.emit('phaseCompleted', phase);
|
||||
this.setState('executing');
|
||||
await this.advanceToNextPhase();
|
||||
}
|
||||
}
|
||||
|
||||
private async handleVerificationFailure(phase: OrchestratorPhase, result: VerificationResult): Promise<void> {
|
||||
if (phase.attempts >= phase.maxAttempts) {
|
||||
// Max retries exceeded
|
||||
phase.status = 'failed';
|
||||
phase.completedAt = Date.now();
|
||||
phase.durationMs = phase.startedAt ? Date.now() - phase.startedAt : null;
|
||||
this.stats.phasesFailed++;
|
||||
this.persist();
|
||||
this.emit('phaseFailed', phase, `Verification failed after ${phase.attempts} attempts: ${result.summary}`);
|
||||
this.setState('failed');
|
||||
return;
|
||||
}
|
||||
|
||||
// Replan and retry
|
||||
this.stats.replanCount++;
|
||||
this.setState('replanning');
|
||||
|
||||
try {
|
||||
await this.replanPhase(phase, result);
|
||||
// Reset task states for retry
|
||||
for (const task of phase.tasks) {
|
||||
task.status = 'pending';
|
||||
task.error = null;
|
||||
task.assignedSessionId = null;
|
||||
task.queueTaskId = null;
|
||||
task.completedAt = null;
|
||||
task.startedAt = null;
|
||||
}
|
||||
phase.status = 'pending';
|
||||
phase.startedAt = null;
|
||||
this.persist();
|
||||
|
||||
this.setState('executing');
|
||||
await this.executeCurrentPhase();
|
||||
} catch (err) {
|
||||
this.handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
private async replanPhase(phase: OrchestratorPhase, result: VerificationResult): Promise<void> {
|
||||
const sessions = this.sessionManager.getIdleSessions();
|
||||
if (sessions.length === 0) return;
|
||||
|
||||
const prompt = REPLAN_PROMPT.replace('{PHASE_NAME}', phase.name)
|
||||
.replace('{ATTEMPT_NUMBER}', String(phase.attempts))
|
||||
.replace('{MAX_ATTEMPTS}', String(phase.maxAttempts))
|
||||
.replace('{FAILURE_SUMMARY}', result.summary)
|
||||
.replace('{SUGGESTIONS}', result.suggestions.join('\n'))
|
||||
.replace('{ORIGINAL_TASKS}', phase.tasks.map((t, i) => `${i + 1}. ${t.prompt}`).join('\n'))
|
||||
.replace('{COMPLETION_PHRASE}', phase.tasks[0]?.completionPhrase || `${phase.id.toUpperCase()}_FIXED`);
|
||||
|
||||
// Send the replan prompt to an idle session
|
||||
await sessions[0].sendInput(prompt);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Internal — State Machine
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private setState(newState: OrchestratorState): void {
|
||||
const prev = this._state;
|
||||
if (prev === newState) return;
|
||||
this._state = newState;
|
||||
this.persist();
|
||||
this.emit('stateChanged', newState, prev);
|
||||
}
|
||||
|
||||
private async advanceToNextPhase(): Promise<void> {
|
||||
this.currentPhaseIndex++;
|
||||
this.phaseSessionIds.clear();
|
||||
this.persist();
|
||||
|
||||
if (!this.plan || this.currentPhaseIndex >= this.plan.phases.length) {
|
||||
await this.handleCompletion();
|
||||
} else {
|
||||
// Compact between phases if configured
|
||||
if (this.config.compactBetweenPhases) {
|
||||
const sessions = this.sessionManager.getIdleSessions();
|
||||
for (const session of sessions) {
|
||||
try {
|
||||
await session.writeViaMux('/compact');
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
}
|
||||
// Brief delay for compact to take effect
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
await this.executeCurrentPhase();
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCompletion(): Promise<void> {
|
||||
this.completedAt = Date.now();
|
||||
this.stats.totalDurationMs = this.startedAt ? this.completedAt - this.startedAt : 0;
|
||||
this.clearPhasePoll();
|
||||
this.cleanupTaskHandlers();
|
||||
this.setState('completed');
|
||||
this.emit('completed', this.stats);
|
||||
}
|
||||
|
||||
private handlePhaseError(phase: OrchestratorPhase, error: string): void {
|
||||
if (phase.attempts >= phase.maxAttempts) {
|
||||
phase.status = 'failed';
|
||||
phase.completedAt = Date.now();
|
||||
phase.durationMs = phase.startedAt ? Date.now() - phase.startedAt : null;
|
||||
this.stats.phasesFailed++;
|
||||
this.persist();
|
||||
this.emit('phaseFailed', phase, error);
|
||||
this.setState('failed');
|
||||
} else {
|
||||
// Retry the phase
|
||||
for (const task of phase.tasks) {
|
||||
if (task.status === 'failed') {
|
||||
task.status = 'pending';
|
||||
task.error = null;
|
||||
task.queueTaskId = null;
|
||||
task.assignedSessionId = null;
|
||||
}
|
||||
}
|
||||
phase.status = 'pending';
|
||||
this.persist();
|
||||
this.executeCurrentPhase().catch((err) => this.handleError(err));
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(err: unknown): void {
|
||||
const error = err instanceof Error ? err : new Error(getErrorMessage(err));
|
||||
console.error('[Orchestrator] Error:', error.message);
|
||||
this.setState('failed');
|
||||
this.emit('error', error);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Internal — Persistence
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private persist(): void {
|
||||
this.store.setOrchestratorState(this.getStatus());
|
||||
}
|
||||
|
||||
private restore(): void {
|
||||
const saved = this.store.getOrchestratorState();
|
||||
if (!saved) return;
|
||||
|
||||
// If we crashed while running, reset to failed
|
||||
if (saved.state === 'executing' || saved.state === 'verifying' || saved.state === 'replanning') {
|
||||
this._state = 'failed';
|
||||
this.plan = saved.plan;
|
||||
this.currentPhaseIndex = saved.currentPhaseIndex;
|
||||
this.startedAt = saved.startedAt;
|
||||
this.config = saved.config;
|
||||
this.stats = saved.stats;
|
||||
this.store.setOrchestratorState({ ...saved, state: 'failed' });
|
||||
} else if (saved.state === 'planning' || saved.state === 'approval') {
|
||||
// Planning/approval — reset to idle (plan is lost)
|
||||
this.store.clearOrchestratorState();
|
||||
} else if (saved.state === 'completed' || saved.state === 'failed') {
|
||||
// Preserve completed/failed state for UI display
|
||||
this._state = saved.state;
|
||||
this.plan = saved.plan;
|
||||
this.currentPhaseIndex = saved.currentPhaseIndex;
|
||||
this.startedAt = saved.startedAt;
|
||||
this.completedAt = saved.completedAt;
|
||||
this.config = saved.config;
|
||||
this.stats = saved.stats;
|
||||
}
|
||||
}
|
||||
|
||||
private reset(): void {
|
||||
this._state = 'idle';
|
||||
this.plan = null;
|
||||
this.currentPhaseIndex = 0;
|
||||
this.startedAt = null;
|
||||
this.completedAt = null;
|
||||
this.stats = createInitialOrchestratorStats();
|
||||
this.pausedState = null;
|
||||
this.phaseSessionIds.clear();
|
||||
this.clearPhasePoll();
|
||||
this.cleanupTaskHandlers();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Internal — Helpers
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private buildTaskPrompt(task: OrchestratorTask, phase: OrchestratorPhase): string {
|
||||
if (phase.tasks.length === 1) {
|
||||
// Single task — use simpler prompt
|
||||
const completedPhases = this.getCompletedPhasesSummary();
|
||||
return SINGLE_TASK_PROMPT.replace('{TASK}', task.prompt)
|
||||
.replace('{GOAL}', this.plan?.goal || '')
|
||||
.replace('{CONTEXT}', completedPhases ? `Previous phases completed: ${completedPhases}` : '')
|
||||
.replace('{COMPLETION_PHRASE}', task.completionPhrase);
|
||||
}
|
||||
|
||||
// Multi-task phase — use full prompt
|
||||
return PHASE_EXECUTION_PROMPT.replace('{PHASE_NAME}', phase.name)
|
||||
.replace('{GOAL}', this.plan?.goal || '')
|
||||
.replace('{COMPLETED_PHASES}', this.getCompletedPhasesSummary() || 'None yet')
|
||||
.replace('{TASK_LIST}', phase.tasks.map((t, i) => `${i + 1}. ${t.prompt}`).join('\n'))
|
||||
.replace('{VERIFICATION_CRITERIA}', phase.verificationCriteria.join('\n') || 'No specific criteria')
|
||||
.replace('{COMPLETION_PHRASE}', task.completionPhrase);
|
||||
}
|
||||
|
||||
private getCompletedPhasesSummary(): string {
|
||||
if (!this.plan) return '';
|
||||
return this.plan.phases
|
||||
.filter((p) => p.status === 'passed' || p.status === 'skipped')
|
||||
.map((p) => `${p.name}: ${p.status}`)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
private findOrchestratorTaskByQueueId(queueTaskId: string): OrchestratorTask | null {
|
||||
if (!this.plan) return null;
|
||||
for (const phase of this.plan.phases) {
|
||||
for (const task of phase.tasks) {
|
||||
if (task.queueTaskId === queueTaskId) return task;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Clean up resources when the loop is being destroyed. */
|
||||
destroy(): void {
|
||||
this.clearPhasePoll();
|
||||
this.cleanupTaskHandlers();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* @fileoverview Orchestrator plan generation — converts goals into phased plans.
|
||||
*
|
||||
* Wraps PlanOrchestrator for AI-powered plan generation, then groups the
|
||||
* resulting PlanItems into sequential phases with team strategies and
|
||||
* verification criteria.
|
||||
*
|
||||
* Phase grouping algorithm:
|
||||
* 1. Topological sort by dependencies (Kahn's algorithm)
|
||||
* 2. Group into dependency layers
|
||||
* 3. Sub-group by TDD phase within layers
|
||||
* 4. Merge small adjacent phases
|
||||
* 5. Assign team strategies based on parallelism potential
|
||||
*
|
||||
* Key exports:
|
||||
* - `OrchestratorPlanner` class — plan generation + phase grouping
|
||||
*
|
||||
* @dependencies plan-orchestrator (AI plan generation), types (OrchestratorPlan, PlanItem)
|
||||
* @consumedby orchestrator-loop
|
||||
*
|
||||
* @module orchestrator-planner
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { PlanOrchestrator, type DetailedPlanResult, type ProgressCallback } from './plan-orchestrator.js';
|
||||
import type { TerminalMultiplexer } from './mux-interface.js';
|
||||
import type {
|
||||
PlanItem,
|
||||
TddPhase,
|
||||
OrchestratorPlan,
|
||||
OrchestratorPhase,
|
||||
OrchestratorTask,
|
||||
OrchestratorConfig,
|
||||
TeamStrategy,
|
||||
PhaseStatus,
|
||||
} from './types.js';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Constants
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Maximum number of phases (prevents runaway plans) */
|
||||
const MAX_PHASES = 10;
|
||||
|
||||
/** Maximum total tasks across all phases */
|
||||
const MAX_TOTAL_TASKS = 50;
|
||||
|
||||
/** Default task timeout (10 minutes) */
|
||||
const DEFAULT_TASK_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
/** Minimum tasks in a phase before it gets merged with adjacent */
|
||||
const MIN_PHASE_TASKS = 2;
|
||||
|
||||
/** TDD phase ordering for grouping */
|
||||
const TDD_PHASE_ORDER: Record<TddPhase, number> = {
|
||||
setup: 0,
|
||||
test: 1,
|
||||
impl: 2,
|
||||
verify: 3,
|
||||
review: 4,
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// OrchestratorPlanner
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export class OrchestratorPlanner {
|
||||
private mux: TerminalMultiplexer;
|
||||
private workingDir: string;
|
||||
private config: OrchestratorConfig;
|
||||
private orchestrator: PlanOrchestrator | null = null;
|
||||
|
||||
constructor(mux: TerminalMultiplexer, workingDir: string, config: OrchestratorConfig) {
|
||||
this.mux = mux;
|
||||
this.workingDir = workingDir;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a phased plan from a user goal.
|
||||
*
|
||||
* Uses PlanOrchestrator for AI plan generation, then groups results into phases.
|
||||
*/
|
||||
async generatePlan(goal: string, onProgress?: ProgressCallback): Promise<OrchestratorPlan> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Create a PlanOrchestrator for this plan generation
|
||||
this.orchestrator = new PlanOrchestrator(this.mux, this.workingDir, undefined, {
|
||||
defaultModel: this.config.plannerModel,
|
||||
});
|
||||
|
||||
try {
|
||||
onProgress?.('planning', 'Generating detailed plan...');
|
||||
|
||||
const result: DetailedPlanResult = await this.orchestrator.generateDetailedPlan(goal, onProgress);
|
||||
|
||||
if (!result.success || !result.items || result.items.length === 0) {
|
||||
throw new Error(result.error || 'Plan generation returned no items');
|
||||
}
|
||||
|
||||
// Cap total tasks
|
||||
const items = result.items.slice(0, MAX_TOTAL_TASKS);
|
||||
|
||||
onProgress?.('grouping', 'Organizing plan into phases...');
|
||||
|
||||
// Group items into phases
|
||||
const phases = this.groupIntoPhases(items, goal);
|
||||
|
||||
// Assign team strategies
|
||||
this.assignTeamStrategies(phases);
|
||||
|
||||
// Generate unique completion phrases
|
||||
this.generateCompletionPhrases(phases);
|
||||
|
||||
const plan: OrchestratorPlan = {
|
||||
id: uuidv4(),
|
||||
goal,
|
||||
createdAt: Date.now(),
|
||||
phases,
|
||||
metadata: {
|
||||
totalTasks: phases.reduce((sum, p) => sum + p.tasks.length, 0),
|
||||
estimatedComplexity: this.estimateComplexity(items),
|
||||
modelUsed: this.config.plannerModel,
|
||||
planDurationMs: Date.now() - startTime,
|
||||
},
|
||||
};
|
||||
|
||||
return plan;
|
||||
} finally {
|
||||
this.orchestrator = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancel in-progress plan generation. */
|
||||
async cancel(): Promise<void> {
|
||||
if (this.orchestrator) {
|
||||
await this.orchestrator.cancel();
|
||||
this.orchestrator = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Phase Grouping
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Group PlanItems into sequential phases.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Build dependency graph and assign IDs to items without them
|
||||
* 2. Topological sort into dependency layers (Kahn's algorithm)
|
||||
* 3. Sub-group within each layer by TDD phase
|
||||
* 4. Merge small phases with their neighbors
|
||||
*/
|
||||
private groupIntoPhases(items: PlanItem[], _goal: string): OrchestratorPhase[] {
|
||||
// Ensure all items have IDs
|
||||
const indexedItems = items.map((item, i) => ({
|
||||
...item,
|
||||
id: item.id || `task-${i}`,
|
||||
}));
|
||||
|
||||
// Build adjacency and in-degree for Kahn's algorithm
|
||||
const idSet = new Set(indexedItems.map((item) => item.id!));
|
||||
const inDegree = new Map<string, number>();
|
||||
const dependents = new Map<string, string[]>(); // id → items that depend on it
|
||||
|
||||
for (const item of indexedItems) {
|
||||
inDegree.set(item.id!, 0);
|
||||
dependents.set(item.id!, []);
|
||||
}
|
||||
|
||||
for (const item of indexedItems) {
|
||||
const deps = (item.dependencies || []).filter((d) => idSet.has(d));
|
||||
inDegree.set(item.id!, deps.length);
|
||||
for (const dep of deps) {
|
||||
dependents.get(dep)!.push(item.id!);
|
||||
}
|
||||
}
|
||||
|
||||
// Kahn's algorithm — produce dependency layers
|
||||
const layers: PlanItem[][] = [];
|
||||
const remaining = new Set(indexedItems.map((item) => item.id!));
|
||||
|
||||
while (remaining.size > 0) {
|
||||
// Find items with no remaining dependencies (in-degree 0)
|
||||
const layer: PlanItem[] = [];
|
||||
for (const id of remaining) {
|
||||
if (inDegree.get(id)! === 0) {
|
||||
layer.push(indexedItems.find((item) => item.id === id)!);
|
||||
}
|
||||
}
|
||||
|
||||
if (layer.length === 0) {
|
||||
// Circular dependency — add all remaining items as a single layer
|
||||
for (const id of remaining) {
|
||||
layer.push(indexedItems.find((item) => item.id === id)!);
|
||||
}
|
||||
}
|
||||
|
||||
layers.push(layer);
|
||||
|
||||
// Remove this layer's items and update in-degrees
|
||||
for (const item of layer) {
|
||||
remaining.delete(item.id!);
|
||||
for (const dep of dependents.get(item.id!) || []) {
|
||||
if (remaining.has(dep)) {
|
||||
inDegree.set(dep, Math.max(0, inDegree.get(dep)! - 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sub-group each layer by TDD phase
|
||||
const rawPhases: PlanItem[][] = [];
|
||||
for (const layer of layers) {
|
||||
const byPhase = new Map<string, PlanItem[]>();
|
||||
for (const item of layer) {
|
||||
const phase = item.tddPhase || 'impl';
|
||||
if (!byPhase.has(phase)) byPhase.set(phase, []);
|
||||
byPhase.get(phase)!.push(item);
|
||||
}
|
||||
|
||||
// Sort sub-groups by TDD phase order
|
||||
const sorted = [...byPhase.entries()].sort(
|
||||
([a], [b]) => (TDD_PHASE_ORDER[a as TddPhase] ?? 2) - (TDD_PHASE_ORDER[b as TddPhase] ?? 2)
|
||||
);
|
||||
|
||||
for (const [, items] of sorted) {
|
||||
rawPhases.push(items);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge small phases with their previous neighbor
|
||||
const mergedPhases: PlanItem[][] = [];
|
||||
for (const phase of rawPhases) {
|
||||
if (mergedPhases.length > 0 && phase.length < MIN_PHASE_TASKS) {
|
||||
const prev = mergedPhases[mergedPhases.length - 1];
|
||||
if (prev.length < MIN_PHASE_TASKS) {
|
||||
// Merge with previous
|
||||
prev.push(...phase);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
mergedPhases.push([...phase]);
|
||||
}
|
||||
|
||||
// Cap at MAX_PHASES by merging tail phases
|
||||
while (mergedPhases.length > MAX_PHASES) {
|
||||
const last = mergedPhases.pop()!;
|
||||
mergedPhases[mergedPhases.length - 1].push(...last);
|
||||
}
|
||||
|
||||
// Convert to OrchestratorPhase objects
|
||||
return mergedPhases.map((phaseItems, index) => this.createPhase(phaseItems, index));
|
||||
}
|
||||
|
||||
private createPhase(items: PlanItem[], order: number): OrchestratorPhase {
|
||||
// Derive phase name from TDD phases and priorities
|
||||
const tddPhases = [...new Set(items.map((i) => i.tddPhase).filter(Boolean))];
|
||||
const name = this.generatePhaseName(items, tddPhases as TddPhase[], order);
|
||||
const description = items.map((i) => i.content).join('; ');
|
||||
|
||||
const tasks: OrchestratorTask[] = items.map((item, i) => ({
|
||||
id: `phase-${order + 1}-task-${i + 1}`,
|
||||
phaseId: `phase-${order + 1}`,
|
||||
prompt: item.content,
|
||||
status: 'pending' as const,
|
||||
assignedSessionId: null,
|
||||
queueTaskId: null,
|
||||
parallel: items.length > 1, // Tasks within a phase are parallel by default
|
||||
completionPhrase: '', // Assigned later
|
||||
timeoutMs: DEFAULT_TASK_TIMEOUT_MS,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
error: null,
|
||||
retries: 0,
|
||||
}));
|
||||
|
||||
// Extract verification criteria and test commands from items
|
||||
const verificationCriteria = items
|
||||
.map((i) => i.verificationCriteria)
|
||||
.filter((v): v is string => v != null && v.length > 0);
|
||||
|
||||
const testCommands = items.map((i) => i.testCommand).filter((t): t is string => t != null && t.length > 0);
|
||||
|
||||
return {
|
||||
id: `phase-${order + 1}`,
|
||||
name,
|
||||
description,
|
||||
order,
|
||||
status: 'pending' as PhaseStatus,
|
||||
tasks,
|
||||
verificationCriteria,
|
||||
testCommands,
|
||||
maxAttempts: this.config.maxPhaseRetries,
|
||||
attempts: 0,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
durationMs: null,
|
||||
teamStrategy: { type: 'single' }, // Assigned later
|
||||
};
|
||||
}
|
||||
|
||||
private generatePhaseName(items: PlanItem[], tddPhases: TddPhase[], order: number): string {
|
||||
// Try to create a meaningful name based on content
|
||||
const priorities = [...new Set(items.map((i) => i.priority).filter(Boolean))];
|
||||
|
||||
if (tddPhases.length === 1) {
|
||||
const phaseNames: Record<TddPhase, string> = {
|
||||
setup: 'Setup & Configuration',
|
||||
test: 'Test Definition',
|
||||
impl: 'Implementation',
|
||||
verify: 'Verification',
|
||||
review: 'Review & Polish',
|
||||
};
|
||||
return `Phase ${order + 1}: ${phaseNames[tddPhases[0]]}`;
|
||||
}
|
||||
|
||||
if (priorities.includes('P0') && priorities.length === 1) {
|
||||
return `Phase ${order + 1}: Critical Foundation`;
|
||||
}
|
||||
|
||||
return `Phase ${order + 1}: ${items.length > 1 ? 'Parallel Tasks' : items[0].content.slice(0, 50)}`;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Team Strategy Assignment
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private assignTeamStrategies(phases: OrchestratorPhase[]): void {
|
||||
for (const phase of phases) {
|
||||
phase.teamStrategy = this.computeTeamStrategy(phase);
|
||||
}
|
||||
}
|
||||
|
||||
private computeTeamStrategy(phase: OrchestratorPhase): TeamStrategy {
|
||||
const taskCount = phase.tasks.length;
|
||||
const parallelTasks = phase.tasks.filter((t) => t.parallel).length;
|
||||
|
||||
// Single task or no parallel potential → single session
|
||||
if (taskCount <= 2 || parallelTasks <= 1) {
|
||||
return { type: 'single' };
|
||||
}
|
||||
|
||||
// If team agents are disabled, use parallel sessions instead
|
||||
if (!this.config.enableTeamAgents) {
|
||||
return {
|
||||
type: 'parallel',
|
||||
maxSessions: Math.min(parallelTasks, this.config.maxParallelSessions),
|
||||
};
|
||||
}
|
||||
|
||||
// 4+ parallel tasks with team agents enabled → team mode
|
||||
if (parallelTasks >= 4) {
|
||||
return {
|
||||
type: 'team',
|
||||
config: {
|
||||
leadPrompt: this.buildTeamLeadPrompt(phase),
|
||||
suggestedTeammates: phase.tasks.slice(0, 4).map((t) => `Specialist for: ${t.prompt.slice(0, 80)}`),
|
||||
maxTeammates: Math.min(parallelTasks, 4),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 3 parallel tasks → parallel sessions
|
||||
return {
|
||||
type: 'parallel',
|
||||
maxSessions: Math.min(parallelTasks, this.config.maxParallelSessions),
|
||||
};
|
||||
}
|
||||
|
||||
private buildTeamLeadPrompt(phase: OrchestratorPhase): string {
|
||||
const taskList = phase.tasks.map((t, i) => `${i + 1}. ${t.prompt}`).join('\n');
|
||||
|
||||
return [
|
||||
`You are the team lead for "${phase.name}".`,
|
||||
`Create teammates and delegate the following tasks for parallel execution:`,
|
||||
'',
|
||||
taskList,
|
||||
'',
|
||||
`Each teammate should focus on one task area.`,
|
||||
`When all tasks are complete, verify the results and output: <promise>${phase.id.toUpperCase()}_COMPLETE</promise>`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Completion Phrases
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private generateCompletionPhrases(phases: OrchestratorPhase[]): void {
|
||||
for (const phase of phases) {
|
||||
for (const task of phase.tasks) {
|
||||
// Generate a unique, deterministic completion phrase per task
|
||||
task.completionPhrase = `ORCH_P${phase.order + 1}_T${phase.tasks.indexOf(task) + 1}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private estimateComplexity(items: PlanItem[]): 'low' | 'medium' | 'high' {
|
||||
const total = items.length;
|
||||
const highComplexity = items.filter((i) => i.complexity === 'high').length;
|
||||
const p0Count = items.filter((i) => i.priority === 'P0').length;
|
||||
|
||||
if (total > 20 || highComplexity > 5 || p0Count > 8) return 'high';
|
||||
if (total > 10 || highComplexity > 2 || p0Count > 4) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* @fileoverview Orchestrator phase verification.
|
||||
*
|
||||
* Runs verification checks after each phase completes:
|
||||
* - Test commands (shell commands via session)
|
||||
* - AI review (ask Claude to evaluate phase results)
|
||||
*
|
||||
* Three verification modes:
|
||||
* - strict: ALL test commands must pass AND AI review must approve
|
||||
* - moderate: Test commands must pass, AI review is advisory
|
||||
* - lenient: At least one test command passes, AI review skipped
|
||||
*
|
||||
* Key exports:
|
||||
* - `OrchestratorVerifier` class — phase verification engine
|
||||
*
|
||||
* @dependencies types (OrchestratorPhase, VerificationResult, VerificationCheck, OrchestratorConfig)
|
||||
* @consumedby orchestrator-loop
|
||||
*
|
||||
* @module orchestrator-verifier
|
||||
*/
|
||||
|
||||
import type { Session } from './session.js';
|
||||
import type { OrchestratorPhase, OrchestratorConfig, VerificationResult, VerificationCheck } from './types.js';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Constants
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Timeout for individual test command execution (2 minutes) */
|
||||
const TEST_COMMAND_TIMEOUT_MS = 2 * 60 * 1000;
|
||||
|
||||
/** Timeout for AI review (3 minutes) */
|
||||
const AI_REVIEW_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
|
||||
/** Completion phrase for AI verification pass */
|
||||
const VERIFY_PASS_PHRASE = 'ORCH_VERIFY_PASS';
|
||||
|
||||
/** Completion phrase for AI verification fail */
|
||||
const VERIFY_FAIL_PHRASE = 'ORCH_VERIFY_FAIL';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// OrchestratorVerifier
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export class OrchestratorVerifier {
|
||||
private config: OrchestratorConfig;
|
||||
|
||||
constructor(config: OrchestratorConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all verification checks for a completed phase.
|
||||
*
|
||||
* @param phase - The phase to verify
|
||||
* @param session - Session to use for running commands/reviews
|
||||
* @returns Verification result with pass/fail and suggestions
|
||||
*/
|
||||
async verifyPhase(phase: OrchestratorPhase, session: Session): Promise<VerificationResult> {
|
||||
const checks: VerificationCheck[] = [];
|
||||
const mode = this.config.verificationMode;
|
||||
|
||||
// Skip verification entirely in lenient mode with no test commands
|
||||
if (mode === 'lenient' && phase.testCommands.length === 0 && phase.verificationCriteria.length === 0) {
|
||||
return {
|
||||
passed: true,
|
||||
checks: [],
|
||||
summary: 'Verification skipped (lenient mode, no checks defined)',
|
||||
suggestions: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Run test commands if any are defined
|
||||
if (phase.testCommands.length > 0) {
|
||||
const testChecks = await this.runTestCommands(phase.testCommands, session);
|
||||
checks.push(...testChecks);
|
||||
}
|
||||
|
||||
// Run AI review in strict and moderate modes
|
||||
if (mode !== 'lenient' && phase.verificationCriteria.length > 0) {
|
||||
const aiCheck = await this.aiReview(phase, session);
|
||||
checks.push(aiCheck);
|
||||
}
|
||||
|
||||
// Determine pass/fail based on mode
|
||||
const passed = this.evaluateChecks(checks, mode);
|
||||
|
||||
// Generate suggestions for failed checks
|
||||
const suggestions = this.generateSuggestions(checks, phase);
|
||||
|
||||
const passedCount = checks.filter((c) => c.passed).length;
|
||||
const summary =
|
||||
checks.length === 0 ? 'No verification checks defined' : `${passedCount}/${checks.length} checks passed`;
|
||||
|
||||
return { passed, checks, summary, suggestions };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Test Command Execution
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private async runTestCommands(commands: string[], session: Session): Promise<VerificationCheck[]> {
|
||||
const checks: VerificationCheck[] = [];
|
||||
|
||||
for (const command of commands) {
|
||||
try {
|
||||
const check = await this.runSingleTestCommand(command, session);
|
||||
checks.push(check);
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
type: 'test_command',
|
||||
description: `Run: ${command}`,
|
||||
passed: false,
|
||||
output: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
private async runSingleTestCommand(command: string, session: Session): Promise<VerificationCheck> {
|
||||
// Send the test command to the session and wait for completion
|
||||
// We use a unique marker to detect when the command finishes
|
||||
const marker = `ORCH_TEST_${Date.now()}`;
|
||||
const wrappedCommand = `${command} && echo ${marker}_PASS || echo ${marker}_FAIL`;
|
||||
|
||||
const result = await this.sendAndWaitForMarker(session, wrappedCommand, marker, TEST_COMMAND_TIMEOUT_MS);
|
||||
|
||||
return {
|
||||
type: 'test_command',
|
||||
description: `Run: ${command}`,
|
||||
passed: result.includes(`${marker}_PASS`),
|
||||
output: result.slice(0, 2000), // Truncate output
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// AI Review
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private async aiReview(phase: OrchestratorPhase, session: Session): Promise<VerificationCheck> {
|
||||
const prompt = this.buildVerificationPrompt(phase);
|
||||
|
||||
try {
|
||||
const result = await this.sendAndWaitForMarker(
|
||||
session,
|
||||
prompt,
|
||||
VERIFY_PASS_PHRASE,
|
||||
AI_REVIEW_TIMEOUT_MS,
|
||||
VERIFY_FAIL_PHRASE
|
||||
);
|
||||
|
||||
const passed = result.includes(VERIFY_PASS_PHRASE);
|
||||
|
||||
return {
|
||||
type: 'ai_review',
|
||||
description: `AI review of "${phase.name}"`,
|
||||
passed,
|
||||
output: result.slice(0, 3000),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
type: 'ai_review',
|
||||
description: `AI review of "${phase.name}"`,
|
||||
passed: false,
|
||||
output: `AI review timed out or failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private buildVerificationPrompt(phase: OrchestratorPhase): string {
|
||||
const criteria = phase.verificationCriteria.map((c, i) => `${i + 1}. ${c}`).join('\n');
|
||||
|
||||
return [
|
||||
`Review the work done in "${phase.name}". Check these criteria:`,
|
||||
'',
|
||||
criteria,
|
||||
'',
|
||||
`If ALL criteria are met, respond with: ${VERIFY_PASS_PHRASE}`,
|
||||
`If ANY criteria fail, respond with: ${VERIFY_FAIL_PHRASE} and explain what failed.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Evaluation
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private evaluateChecks(checks: VerificationCheck[], mode: OrchestratorConfig['verificationMode']): boolean {
|
||||
if (checks.length === 0) return true;
|
||||
|
||||
const testChecks = checks.filter((c) => c.type === 'test_command');
|
||||
const aiChecks = checks.filter((c) => c.type === 'ai_review');
|
||||
|
||||
switch (mode) {
|
||||
case 'strict':
|
||||
// ALL checks must pass
|
||||
return checks.every((c) => c.passed);
|
||||
|
||||
case 'moderate':
|
||||
// All test commands must pass; AI review is advisory
|
||||
return testChecks.length === 0 || testChecks.every((c) => c.passed);
|
||||
|
||||
case 'lenient':
|
||||
// At least one test passes (AI review skipped in lenient mode)
|
||||
return testChecks.length === 0 || testChecks.some((c) => c.passed);
|
||||
|
||||
default:
|
||||
return aiChecks.every((c) => c.passed) && testChecks.every((c) => c.passed);
|
||||
}
|
||||
}
|
||||
|
||||
private generateSuggestions(checks: VerificationCheck[], phase: OrchestratorPhase): string[] {
|
||||
const suggestions: string[] = [];
|
||||
const failedChecks = checks.filter((c) => !c.passed);
|
||||
|
||||
if (failedChecks.length === 0) return suggestions;
|
||||
|
||||
for (const check of failedChecks) {
|
||||
if (check.type === 'test_command') {
|
||||
suggestions.push(`Fix failing test: ${check.description}`);
|
||||
} else if (check.type === 'ai_review' && check.output) {
|
||||
// Extract failure reasons from AI review output
|
||||
suggestions.push(`Address AI review feedback for "${phase.name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session Communication
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Send a prompt to a session and wait for a marker phrase in the output.
|
||||
*
|
||||
* @param session - Session to send to
|
||||
* @param input - Prompt/command to send
|
||||
* @param marker - Primary marker to watch for
|
||||
* @param timeoutMs - Maximum wait time
|
||||
* @param altMarker - Alternative marker (for pass/fail detection)
|
||||
* @returns Captured output containing the marker
|
||||
*/
|
||||
private sendAndWaitForMarker(
|
||||
session: Session,
|
||||
input: string,
|
||||
marker: string,
|
||||
timeoutMs: number,
|
||||
altMarker?: string
|
||||
): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
let output = '';
|
||||
let resolved = false;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
cleanup();
|
||||
reject(new Error(`Timeout waiting for marker "${marker}" after ${timeoutMs}ms`));
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
const handler = (data: string) => {
|
||||
if (resolved) return;
|
||||
output += data;
|
||||
|
||||
if (output.includes(marker) || (altMarker && output.includes(altMarker))) {
|
||||
resolved = true;
|
||||
cleanup();
|
||||
resolve(output);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
session.off('terminal', handler);
|
||||
};
|
||||
|
||||
session.on('terminal', handler);
|
||||
|
||||
// Send the input
|
||||
session.sendInput(input).catch((err) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
cleanup();
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,3 +7,10 @@
|
||||
|
||||
export { RESEARCH_AGENT_PROMPT } from './research-agent.js';
|
||||
export { PLANNER_PROMPT } from './planner.js';
|
||||
export {
|
||||
PHASE_EXECUTION_PROMPT,
|
||||
TEAM_LEAD_PROMPT,
|
||||
VERIFICATION_PROMPT,
|
||||
REPLAN_PROMPT,
|
||||
SINGLE_TASK_PROMPT,
|
||||
} from './orchestrator.js';
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* @fileoverview Orchestrator Loop prompt templates.
|
||||
*
|
||||
* Templates for phase execution, team delegation, verification, and replanning.
|
||||
* Placeholders use {VARIABLE} syntax and are replaced at runtime.
|
||||
*
|
||||
* @module prompts/orchestrator
|
||||
*/
|
||||
|
||||
/**
|
||||
* Phase execution prompt — tells Claude what to accomplish in this phase.
|
||||
*
|
||||
* Placeholders:
|
||||
* - {PHASE_NUMBER}: Phase index (1-based)
|
||||
* - {PHASE_NAME}: Human-readable phase name
|
||||
* - {GOAL}: Original user goal
|
||||
* - {COMPLETED_PHASES}: Summary of previously completed phases
|
||||
* - {TASK_LIST}: Numbered task list for this phase
|
||||
* - {VERIFICATION_CRITERIA}: What will be checked after this phase
|
||||
* - {COMPLETION_PHRASE}: The phrase to output when done
|
||||
*/
|
||||
export const PHASE_EXECUTION_PROMPT = `You are executing {PHASE_NAME} of a larger project.
|
||||
|
||||
OVERALL GOAL: {GOAL}
|
||||
|
||||
COMPLETED SO FAR:
|
||||
{COMPLETED_PHASES}
|
||||
|
||||
YOUR TASKS FOR THIS PHASE:
|
||||
{TASK_LIST}
|
||||
|
||||
Complete each task thoroughly. Run tests after each change to catch issues early.
|
||||
|
||||
VERIFICATION (will be checked after you finish):
|
||||
{VERIFICATION_CRITERIA}
|
||||
|
||||
When ALL tasks in this phase are complete and verified, output: <promise>{COMPLETION_PHRASE}</promise>`;
|
||||
|
||||
/**
|
||||
* Team lead delegation prompt — instructs a lead to coordinate teammates.
|
||||
*
|
||||
* Placeholders:
|
||||
* - {PHASE_NAME}: Phase name
|
||||
* - {TASK_LIST}: Numbered task list
|
||||
* - {TEAMMATE_HINTS}: Suggested teammate specializations
|
||||
* - {COMPLETION_PHRASE}: Phrase for when all work is done
|
||||
*/
|
||||
export const TEAM_LEAD_PROMPT = `You are the team lead for {PHASE_NAME}.
|
||||
|
||||
Create teammates and delegate the following tasks for parallel execution:
|
||||
|
||||
{TASK_LIST}
|
||||
|
||||
Suggested teammate roles:
|
||||
{TEAMMATE_HINTS}
|
||||
|
||||
Each teammate should focus on their assigned task area. Monitor their progress.
|
||||
When ALL tasks are complete and you've verified the results, output: <promise>{COMPLETION_PHRASE}</promise>`;
|
||||
|
||||
/**
|
||||
* Verification prompt — asks Claude to verify phase completion.
|
||||
*
|
||||
* Placeholders:
|
||||
* - {PHASE_NAME}: Phase name
|
||||
* - {CRITERIA}: Numbered verification criteria
|
||||
* - {PASS_PHRASE}: Phrase to output on success
|
||||
* - {FAIL_PHRASE}: Phrase to output on failure
|
||||
*/
|
||||
export const VERIFICATION_PROMPT = `Review the work done in "{PHASE_NAME}". Check these criteria:
|
||||
|
||||
{CRITERIA}
|
||||
|
||||
If ALL criteria are met, respond with: {PASS_PHRASE}
|
||||
If ANY criteria fail, respond with: {FAIL_PHRASE} and explain what failed.`;
|
||||
|
||||
/**
|
||||
* Replan prompt — gives failure context and asks for recovery.
|
||||
*
|
||||
* Placeholders:
|
||||
* - {PHASE_NAME}: Phase name
|
||||
* - {ATTEMPT_NUMBER}: Current retry attempt
|
||||
* - {MAX_ATTEMPTS}: Maximum attempts allowed
|
||||
* - {FAILURE_SUMMARY}: What went wrong
|
||||
* - {SUGGESTIONS}: Recovery suggestions from verification
|
||||
* - {ORIGINAL_TASKS}: The original task list
|
||||
* - {COMPLETION_PHRASE}: Phrase for when recovery is done
|
||||
*/
|
||||
export const REPLAN_PROMPT = `Phase "{PHASE_NAME}" verification failed (attempt {ATTEMPT_NUMBER}/{MAX_ATTEMPTS}).
|
||||
|
||||
WHAT WENT WRONG:
|
||||
{FAILURE_SUMMARY}
|
||||
|
||||
SUGGESTIONS:
|
||||
{SUGGESTIONS}
|
||||
|
||||
ORIGINAL TASKS:
|
||||
{ORIGINAL_TASKS}
|
||||
|
||||
Fix the issues identified above. Focus on making the verification criteria pass.
|
||||
When the fixes are complete, output: <promise>{COMPLETION_PHRASE}</promise>`;
|
||||
|
||||
/**
|
||||
* Single-task execution prompt — for phases with a single task.
|
||||
*
|
||||
* Placeholders:
|
||||
* - {TASK}: The task description
|
||||
* - {GOAL}: Original user goal
|
||||
* - {CONTEXT}: Any relevant context
|
||||
* - {COMPLETION_PHRASE}: Phrase for when done
|
||||
*/
|
||||
export const SINGLE_TASK_PROMPT = `{TASK}
|
||||
|
||||
Context: This is part of a larger project — {GOAL}
|
||||
{CONTEXT}
|
||||
|
||||
When done, output: <promise>{COMPLETION_PHRASE}</promise>`;
|
||||
@@ -547,6 +547,30 @@ export class StateStore {
|
||||
this.save();
|
||||
}
|
||||
|
||||
// ========== Orchestrator Loop State Methods ==========
|
||||
|
||||
/** Returns the orchestrator loop state, or null if never initialized. */
|
||||
getOrchestratorState() {
|
||||
return this.state.orchestrator ?? null;
|
||||
}
|
||||
|
||||
/** Updates orchestrator loop state (partial merge) and triggers a debounced save. */
|
||||
setOrchestratorState(orchestrator: Partial<NonNullable<AppState['orchestrator']>>) {
|
||||
if (this.state.orchestrator) {
|
||||
this.state.orchestrator = { ...this.state.orchestrator, ...orchestrator };
|
||||
} else {
|
||||
// First initialization — caller must provide full state
|
||||
this.state.orchestrator = orchestrator as NonNullable<AppState['orchestrator']>;
|
||||
}
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Clears orchestrator state and triggers a debounced save. */
|
||||
clearOrchestratorState() {
|
||||
this.state.orchestrator = undefined;
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Returns the application configuration. */
|
||||
getConfig() {
|
||||
return this.state.config;
|
||||
|
||||
@@ -109,6 +109,8 @@ export interface AppState {
|
||||
globalStats?: GlobalStats;
|
||||
/** Daily token usage statistics */
|
||||
tokenStats?: TokenStats;
|
||||
/** Orchestrator Loop state (phased plan execution) */
|
||||
orchestrator?: import('./orchestrator.js').OrchestratorPersistState;
|
||||
}
|
||||
|
||||
// ========== Default Configuration ==========
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
* | teams | TeamConfig, TeamMember, TeamTask, InboxMessage, PaneInfo | `~/.claude/teams/`, `~/.claude/tasks/` → `GET /api/teams` |
|
||||
* | push | PushSubscriptionRecord, VapidKeys | `~/.codeman/push-keys.json`, `~/.codeman/push-subscriptions.json` |
|
||||
* | plan | PlanItem, PlanTaskStatus, TddPhase | In-memory → `GET /api/sessions/:id/plan/tasks` |
|
||||
* | orchestrator | OrchestratorState, OrchestratorPlan, OrchestratorConfig, OrchestratorPersistState | `~/.codeman/state.json` → `GET /api/orchestrator/status` |
|
||||
*
|
||||
* ## Cross-domain relationship map
|
||||
*
|
||||
@@ -64,3 +65,4 @@ export * from './tools.js';
|
||||
export * from './teams.js';
|
||||
export * from './push.js';
|
||||
export * from './plan.js';
|
||||
export * from './orchestrator.js';
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* @fileoverview Orchestrator Loop type definitions.
|
||||
*
|
||||
* Types for the phased plan execution system: state machine, plan structure,
|
||||
* phase grouping, team strategies, verification, configuration, and persistence.
|
||||
*
|
||||
* Key exports:
|
||||
* - OrchestratorState — state machine states (idle → planning → approval → executing → verifying → ...)
|
||||
* - OrchestratorPlan / OrchestratorPhase / OrchestratorTask — hierarchical plan structure
|
||||
* - TeamStrategy — how agents are coordinated per phase (single, parallel, team)
|
||||
* - VerificationResult / VerificationCheck — phase verification output
|
||||
* - OrchestratorConfig — user-configurable options
|
||||
* - OrchestratorPersistState / OrchestratorStats — persistence and metrics
|
||||
*
|
||||
* Cross-domain relationships:
|
||||
* - OrchestratorTask.queueTaskId links to TaskState.id (task domain)
|
||||
* - OrchestratorTask.assignedSessionId links to SessionState.id (session domain)
|
||||
* - OrchestratorPersistState is embedded in AppState.orchestrator (app-state domain)
|
||||
*
|
||||
* Served at `GET /api/orchestrator/status` and `GET /api/orchestrator/plan`.
|
||||
* No dependencies on other domain modules.
|
||||
*/
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// State Machine
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Orchestrator loop states */
|
||||
export type OrchestratorState =
|
||||
| 'idle'
|
||||
| 'planning'
|
||||
| 'approval'
|
||||
| 'executing'
|
||||
| 'verifying'
|
||||
| 'replanning'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'paused';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Plan Structure
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Top-level orchestrator plan generated from a user goal */
|
||||
export interface OrchestratorPlan {
|
||||
/** Unique plan identifier */
|
||||
id: string;
|
||||
/** Original user goal/task description */
|
||||
goal: string;
|
||||
/** When the plan was generated */
|
||||
createdAt: number;
|
||||
/** Ordered list of execution phases */
|
||||
phases: OrchestratorPhase[];
|
||||
/** Plan generation metadata */
|
||||
metadata: OrchestratorPlanMetadata;
|
||||
}
|
||||
|
||||
/** Metadata from plan generation */
|
||||
export interface OrchestratorPlanMetadata {
|
||||
/** Total tasks across all phases */
|
||||
totalTasks: number;
|
||||
/** Estimated overall complexity */
|
||||
estimatedComplexity: 'low' | 'medium' | 'high';
|
||||
/** Model used for plan generation */
|
||||
modelUsed: string;
|
||||
/** Time taken to generate the plan */
|
||||
planDurationMs: number;
|
||||
}
|
||||
|
||||
/** A sequential execution phase containing parallel tasks */
|
||||
export interface OrchestratorPhase {
|
||||
/** Phase identifier (e.g., "phase-1") */
|
||||
id: string;
|
||||
/** Human-readable phase name */
|
||||
name: string;
|
||||
/** Detailed description of what this phase accomplishes */
|
||||
description: string;
|
||||
/** Execution order (0-based) */
|
||||
order: number;
|
||||
/** Current phase status */
|
||||
status: PhaseStatus;
|
||||
/** Tasks within this phase */
|
||||
tasks: OrchestratorTask[];
|
||||
/** Criteria to verify after phase completion */
|
||||
verificationCriteria: string[];
|
||||
/** Shell commands to run for verification */
|
||||
testCommands: string[];
|
||||
/** Maximum retry attempts for this phase */
|
||||
maxAttempts: number;
|
||||
/** Current attempt count */
|
||||
attempts: number;
|
||||
/** When execution started */
|
||||
startedAt: number | null;
|
||||
/** When phase completed (passed or failed) */
|
||||
completedAt: number | null;
|
||||
/** Total execution duration */
|
||||
durationMs: number | null;
|
||||
/** How to coordinate agents for this phase */
|
||||
teamStrategy: TeamStrategy;
|
||||
}
|
||||
|
||||
/** Phase execution status */
|
||||
export type PhaseStatus = 'pending' | 'executing' | 'verifying' | 'passed' | 'failed' | 'skipped';
|
||||
|
||||
/** A single executable task within a phase */
|
||||
export interface OrchestratorTask {
|
||||
/** Task identifier (e.g., "phase-1-task-1") */
|
||||
id: string;
|
||||
/** Parent phase identifier */
|
||||
phaseId: string;
|
||||
/** Single-line prompt to send to Claude */
|
||||
prompt: string;
|
||||
/** Current task status */
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
/** Session running this task */
|
||||
assignedSessionId: string | null;
|
||||
/** Links to TaskQueue task ID (for completion tracking) */
|
||||
queueTaskId: string | null;
|
||||
/** Whether this task can run in parallel with siblings */
|
||||
parallel: boolean;
|
||||
/** Unique phrase for completion detection */
|
||||
completionPhrase: string;
|
||||
/** Timeout in milliseconds */
|
||||
timeoutMs: number;
|
||||
/** When task started executing */
|
||||
startedAt: number | null;
|
||||
/** When task completed */
|
||||
completedAt: number | null;
|
||||
/** Error message if task failed */
|
||||
error: string | null;
|
||||
/** Number of retry attempts */
|
||||
retries: number;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Team Strategy
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** How agents are coordinated for a phase */
|
||||
export type TeamStrategy =
|
||||
| { type: 'single' }
|
||||
| { type: 'parallel'; maxSessions: number }
|
||||
| { type: 'team'; config: TeamSetup };
|
||||
|
||||
/** Configuration for team-based phase execution */
|
||||
export interface TeamSetup {
|
||||
/** Prompt to send to the team lead */
|
||||
leadPrompt: string;
|
||||
/** Suggested teammate role descriptions */
|
||||
suggestedTeammates: string[];
|
||||
/** Maximum number of teammates to create */
|
||||
maxTeammates: number;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Verification
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Result of phase verification */
|
||||
export interface VerificationResult {
|
||||
/** Whether all checks passed */
|
||||
passed: boolean;
|
||||
/** Individual verification checks */
|
||||
checks: VerificationCheck[];
|
||||
/** Human-readable summary */
|
||||
summary: string;
|
||||
/** Suggestions for replanning if verification failed */
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
/** A single verification check result */
|
||||
export interface VerificationCheck {
|
||||
/** Type of check performed */
|
||||
type: 'test_command' | 'ai_review' | 'file_check';
|
||||
/** What was checked */
|
||||
description: string;
|
||||
/** Whether this check passed */
|
||||
passed: boolean;
|
||||
/** Command output or review text */
|
||||
output?: string;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Configuration
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** User-configurable orchestrator options */
|
||||
export interface OrchestratorConfig {
|
||||
/** Model to use for plan generation (default: 'opus') */
|
||||
plannerModel: string;
|
||||
/** Whether to run research agent before planning (default: true) */
|
||||
researchEnabled: boolean;
|
||||
/** Auto-approve generated plans without user review (default: false) */
|
||||
autoApprove: boolean;
|
||||
/** Maximum retry attempts per phase (default: 3) */
|
||||
maxPhaseRetries: number;
|
||||
/** Phase execution timeout in ms (default: 1800000 = 30min) */
|
||||
phaseTimeoutMs: number;
|
||||
/** Enable Claude Code agent teams for parallel phases (default: true) */
|
||||
enableTeamAgents: boolean;
|
||||
/** Maximum parallel sessions for task execution (default: 3) */
|
||||
maxParallelSessions: number;
|
||||
/** Verification strictness (default: 'moderate') */
|
||||
verificationMode: 'strict' | 'moderate' | 'lenient';
|
||||
/** Run /compact between phases to manage context (default: true) */
|
||||
compactBetweenPhases: boolean;
|
||||
}
|
||||
|
||||
/** Default orchestrator configuration */
|
||||
export const DEFAULT_ORCHESTRATOR_CONFIG: OrchestratorConfig = {
|
||||
plannerModel: 'opus',
|
||||
researchEnabled: true,
|
||||
autoApprove: false,
|
||||
maxPhaseRetries: 3,
|
||||
phaseTimeoutMs: 30 * 60 * 1000, // 30 minutes
|
||||
enableTeamAgents: true,
|
||||
maxParallelSessions: 3,
|
||||
verificationMode: 'moderate',
|
||||
compactBetweenPhases: true,
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Persistence
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Orchestrator state persisted to ~/.codeman/state.json */
|
||||
export interface OrchestratorPersistState {
|
||||
/** Current state machine state */
|
||||
state: OrchestratorState;
|
||||
/** Generated plan (null before planning) */
|
||||
plan: OrchestratorPlan | null;
|
||||
/** Index of currently executing phase */
|
||||
currentPhaseIndex: number;
|
||||
/** When orchestration started */
|
||||
startedAt: number | null;
|
||||
/** When orchestration completed */
|
||||
completedAt: number | null;
|
||||
/** User configuration */
|
||||
config: OrchestratorConfig;
|
||||
/** Execution statistics */
|
||||
stats: OrchestratorStats;
|
||||
}
|
||||
|
||||
/** Orchestrator execution statistics */
|
||||
export interface OrchestratorStats {
|
||||
/** Number of phases completed successfully */
|
||||
phasesCompleted: number;
|
||||
/** Number of phases that failed (after all retries) */
|
||||
phasesFailed: number;
|
||||
/** Total individual tasks completed */
|
||||
totalTasksCompleted: number;
|
||||
/** Total individual tasks failed */
|
||||
totalTasksFailed: number;
|
||||
/** Total time spent executing (ms) */
|
||||
totalDurationMs: number;
|
||||
/** Number of times replanning was triggered */
|
||||
replanCount: number;
|
||||
}
|
||||
|
||||
/** Factory function for initial orchestrator stats */
|
||||
export function createInitialOrchestratorStats(): OrchestratorStats {
|
||||
return {
|
||||
phasesCompleted: 0,
|
||||
phasesFailed: 0,
|
||||
totalTasksCompleted: 0,
|
||||
totalTasksFailed: 0,
|
||||
totalDurationMs: 0,
|
||||
replanCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Factory function for initial orchestrator persist state */
|
||||
export function createInitialOrchestratorPersistState(
|
||||
config: OrchestratorConfig = DEFAULT_ORCHESTRATOR_CONFIG
|
||||
): OrchestratorPersistState {
|
||||
return {
|
||||
state: 'idle',
|
||||
plan: null,
|
||||
currentPhaseIndex: 0,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
config,
|
||||
stats: createInitialOrchestratorStats(),
|
||||
};
|
||||
}
|
||||
@@ -12,3 +12,4 @@ export type { RespawnPort } from './respawn-port.js';
|
||||
export type { ConfigPort } from './config-port.js';
|
||||
export type { InfraPort, ScheduledRun } from './infra-port.js';
|
||||
export type { AuthPort, AuthSessionRecord } from './auth-port.js';
|
||||
export type { OrchestratorPort } from './orchestrator-port.js';
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* @fileoverview Orchestrator port — capabilities for orchestrator loop management.
|
||||
* Route modules that interact with the orchestrator depend on this port.
|
||||
*/
|
||||
|
||||
import type { OrchestratorLoop } from '../../orchestrator-loop.js';
|
||||
|
||||
export interface OrchestratorPort {
|
||||
readonly orchestratorLoop: OrchestratorLoop | null;
|
||||
initOrchestratorLoop(): OrchestratorLoop;
|
||||
}
|
||||
@@ -235,6 +235,19 @@ const _SSE_HANDLER_MAP = [
|
||||
[SSE_EVENTS.PLAN_STARTED, '_onPlanStarted'],
|
||||
[SSE_EVENTS.PLAN_CANCELLED, '_onPlanCancelled'],
|
||||
[SSE_EVENTS.PLAN_COMPLETED, '_onPlanCompleted'],
|
||||
|
||||
// Orchestrator loop
|
||||
[SSE_EVENTS.ORCHESTRATOR_STATE_CHANGED, '_onOrchestratorStateChanged'],
|
||||
[SSE_EVENTS.ORCHESTRATOR_PLAN_READY, '_onOrchestratorPlanReady'],
|
||||
[SSE_EVENTS.ORCHESTRATOR_PHASE_STARTED, '_onOrchestratorPhaseStarted'],
|
||||
[SSE_EVENTS.ORCHESTRATOR_PHASE_COMPLETED, '_onOrchestratorPhaseCompleted'],
|
||||
[SSE_EVENTS.ORCHESTRATOR_PHASE_FAILED, '_onOrchestratorPhaseFailed'],
|
||||
[SSE_EVENTS.ORCHESTRATOR_VERIFICATION, '_onOrchestratorVerification'],
|
||||
[SSE_EVENTS.ORCHESTRATOR_TASK_ASSIGNED, '_onOrchestratorTaskAssigned'],
|
||||
[SSE_EVENTS.ORCHESTRATOR_TASK_COMPLETED, '_onOrchestratorTaskCompleted'],
|
||||
[SSE_EVENTS.ORCHESTRATOR_TASK_FAILED, '_onOrchestratorTaskFailed'],
|
||||
[SSE_EVENTS.ORCHESTRATOR_COMPLETED, '_onOrchestratorCompleted'],
|
||||
[SSE_EVENTS.ORCHESTRATOR_ERROR, '_onOrchestratorError'],
|
||||
];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -317,6 +330,10 @@ class CodemanApp {
|
||||
this.logViewerWindows = new Map(); // Map<windowId, { element, eventSource, filePath }>
|
||||
this.logViewerWindowZIndex = ZINDEX_LOG_VIEWER_BASE;
|
||||
this.projectInsightsPanelVisible = false;
|
||||
|
||||
// Orchestrator loop state
|
||||
this.orchestratorState = null; // { state, plan, currentPhaseIndex, stats }
|
||||
this.orchestratorPanelVisible = false;
|
||||
this.currentSessionWorkingDir = null; // Track current session's working dir for path normalization
|
||||
|
||||
// Image popup windows (auto-open for detected screenshots/images)
|
||||
|
||||
@@ -277,6 +277,19 @@ const SSE_EVENTS = {
|
||||
PLAN_STARTED: 'plan:started',
|
||||
PLAN_CANCELLED: 'plan:cancelled',
|
||||
PLAN_COMPLETED: 'plan:completed',
|
||||
|
||||
// Orchestrator Loop
|
||||
ORCHESTRATOR_STATE_CHANGED: 'orchestrator:stateChanged',
|
||||
ORCHESTRATOR_PLAN_READY: 'orchestrator:planReady',
|
||||
ORCHESTRATOR_PHASE_STARTED: 'orchestrator:phaseStarted',
|
||||
ORCHESTRATOR_PHASE_COMPLETED: 'orchestrator:phaseCompleted',
|
||||
ORCHESTRATOR_PHASE_FAILED: 'orchestrator:phaseFailed',
|
||||
ORCHESTRATOR_VERIFICATION: 'orchestrator:verification',
|
||||
ORCHESTRATOR_TASK_ASSIGNED: 'orchestrator:taskAssigned',
|
||||
ORCHESTRATOR_TASK_COMPLETED: 'orchestrator:taskCompleted',
|
||||
ORCHESTRATOR_TASK_FAILED: 'orchestrator:taskFailed',
|
||||
ORCHESTRATOR_COMPLETED: 'orchestrator:completed',
|
||||
ORCHESTRATOR_ERROR: 'orchestrator:error',
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -137,6 +137,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Orchestrator Loop Panel -->
|
||||
<div class="orchestrator-panel" id="orchestratorPanel" style="display: none;">
|
||||
<div class="orchestrator-header">
|
||||
<span class="orchestrator-title">Orchestrator</span>
|
||||
<span class="orchestrator-state-badge" id="orchestratorStateBadge">idle</span>
|
||||
<div class="orchestrator-actions" id="orchestratorActions"></div>
|
||||
<button class="orchestrator-close-btn" onclick="app.closeOrchestratorPanel()" title="Close">×</button>
|
||||
</div>
|
||||
<div class="orchestrator-body" id="orchestratorBody"></div>
|
||||
</div>
|
||||
|
||||
<!-- Ralph / Todo Tracker Panel -->
|
||||
<div class="ralph-panel collapsed" id="ralphStatePanel" style="display: none;">
|
||||
<!-- Collapsed Summary Bar -->
|
||||
@@ -1704,6 +1715,7 @@
|
||||
<script defer src="terminal-ui.js"></script>
|
||||
<script defer src="respawn-ui.js"></script>
|
||||
<script defer src="ralph-panel.js"></script>
|
||||
<script defer src="orchestrator-panel.js"></script>
|
||||
<script defer src="settings-ui.js"></script>
|
||||
<script defer src="panels-ui.js"></script>
|
||||
<script defer src="session-ui.js"></script>
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* @fileoverview Orchestrator loop panel — plan-based autonomous execution UI.
|
||||
* Shows orchestrator state, plan phases, task progress, and verification results.
|
||||
* Provides controls for start, approve, reject, pause, resume, stop, skip, retry.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (CodemanApp class, this.orchestratorState)
|
||||
* @dependency constants.js (SSE_EVENTS, escapeHtml)
|
||||
* @loadorder 9.5 of 16 — loaded after ralph-panel.js, before settings-ui.js
|
||||
*/
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// State color/label mappings
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const ORCH_STATE_COLORS = {
|
||||
idle: '#6b7280',
|
||||
planning: '#f59e0b',
|
||||
approval: '#8b5cf6',
|
||||
executing: '#3b82f6',
|
||||
verifying: '#06b6d4',
|
||||
replanning: '#f97316',
|
||||
completed: '#22c55e',
|
||||
failed: '#ef4444',
|
||||
paused: '#9ca3af',
|
||||
};
|
||||
|
||||
const ORCH_PHASE_STATUS_ICONS = {
|
||||
pending: '\u25cb', // ○
|
||||
executing: '\u25d4', // ◔
|
||||
passed: '\u2713', // ✓
|
||||
failed: '\u2717', // ✗
|
||||
skipped: '\u2192', // →
|
||||
};
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SSE Event Handlers
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
_onOrchestratorStateChanged(data) {
|
||||
if (!this.orchestratorState) this.orchestratorState = {};
|
||||
this.orchestratorState.state = data.state;
|
||||
this.renderOrchestratorPanel();
|
||||
},
|
||||
|
||||
_onOrchestratorPlanReady(data) {
|
||||
if (!this.orchestratorState) this.orchestratorState = {};
|
||||
this.orchestratorState.plan = data.plan;
|
||||
this.orchestratorState.state = 'approval';
|
||||
this.showOrchestratorPanel();
|
||||
this.renderOrchestratorPanel();
|
||||
},
|
||||
|
||||
_onOrchestratorPhaseStarted(data) {
|
||||
if (!this.orchestratorState) return;
|
||||
this._updateOrchestratorPhase(data.phase);
|
||||
this.renderOrchestratorPanel();
|
||||
},
|
||||
|
||||
_onOrchestratorPhaseCompleted(data) {
|
||||
if (!this.orchestratorState) return;
|
||||
this._updateOrchestratorPhase(data.phase);
|
||||
this.renderOrchestratorPanel();
|
||||
},
|
||||
|
||||
_onOrchestratorPhaseFailed(data) {
|
||||
if (!this.orchestratorState) return;
|
||||
this._updateOrchestratorPhase(data.phase);
|
||||
this.renderOrchestratorPanel();
|
||||
},
|
||||
|
||||
_onOrchestratorVerification(data) {
|
||||
if (!this.orchestratorState) return;
|
||||
// Store verification result on the phase
|
||||
if (this.orchestratorState.plan) {
|
||||
const phase = this.orchestratorState.plan.phases.find(p => p.id === data.phaseId);
|
||||
if (phase) phase._lastVerification = data.result;
|
||||
}
|
||||
this.renderOrchestratorPanel();
|
||||
},
|
||||
|
||||
_onOrchestratorTaskAssigned(data) {
|
||||
if (!this.orchestratorState) return;
|
||||
this._updateOrchestratorTask(data.task);
|
||||
this.renderOrchestratorPanel();
|
||||
},
|
||||
|
||||
_onOrchestratorTaskCompleted(data) {
|
||||
if (!this.orchestratorState) return;
|
||||
this._updateOrchestratorTask(data.task);
|
||||
this.renderOrchestratorPanel();
|
||||
},
|
||||
|
||||
_onOrchestratorTaskFailed(data) {
|
||||
if (!this.orchestratorState) return;
|
||||
this._updateOrchestratorTask(data.task);
|
||||
this.renderOrchestratorPanel();
|
||||
},
|
||||
|
||||
_onOrchestratorCompleted(data) {
|
||||
if (!this.orchestratorState) this.orchestratorState = {};
|
||||
this.orchestratorState.state = 'completed';
|
||||
this.orchestratorState.stats = data.stats;
|
||||
this.renderOrchestratorPanel();
|
||||
},
|
||||
|
||||
_onOrchestratorError(data) {
|
||||
if (!this.orchestratorState) this.orchestratorState = {};
|
||||
this.orchestratorState.state = 'failed';
|
||||
this.orchestratorState.lastError = data.error;
|
||||
this.renderOrchestratorPanel();
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Internal helpers
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
_updateOrchestratorPhase(updatedPhase) {
|
||||
if (!this.orchestratorState?.plan) return;
|
||||
const idx = this.orchestratorState.plan.phases.findIndex(p => p.id === updatedPhase.id);
|
||||
if (idx >= 0) this.orchestratorState.plan.phases[idx] = updatedPhase;
|
||||
},
|
||||
|
||||
_updateOrchestratorTask(updatedTask) {
|
||||
if (!this.orchestratorState?.plan) return;
|
||||
for (const phase of this.orchestratorState.plan.phases) {
|
||||
const idx = phase.tasks.findIndex(t => t.id === updatedTask.id);
|
||||
if (idx >= 0) {
|
||||
phase.tasks[idx] = updatedTask;
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Panel visibility
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
showOrchestratorPanel() {
|
||||
this.orchestratorPanelVisible = true;
|
||||
const panel = document.getElementById('orchestratorPanel');
|
||||
if (panel) panel.style.display = '';
|
||||
this.renderOrchestratorPanel();
|
||||
},
|
||||
|
||||
closeOrchestratorPanel() {
|
||||
this.orchestratorPanelVisible = false;
|
||||
const panel = document.getElementById('orchestratorPanel');
|
||||
if (panel) panel.style.display = 'none';
|
||||
},
|
||||
|
||||
toggleOrchestratorPanel() {
|
||||
if (this.orchestratorPanelVisible) {
|
||||
this.closeOrchestratorPanel();
|
||||
} else {
|
||||
this.showOrchestratorPanel();
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// API calls
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
async orchestratorStart(goal, config) {
|
||||
try {
|
||||
const res = await fetch('/api/orchestrator/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ goal, config }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
this.orchestratorState = { state: 'planning', plan: null };
|
||||
this.showOrchestratorPanel();
|
||||
this.renderOrchestratorPanel();
|
||||
}
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error('[Orchestrator] Start failed:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async orchestratorApprove() {
|
||||
try {
|
||||
await fetch('/api/orchestrator/approve', { method: 'POST' });
|
||||
} catch (err) {
|
||||
console.error('[Orchestrator] Approve failed:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async orchestratorReject(feedback) {
|
||||
try {
|
||||
await fetch('/api/orchestrator/reject', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ feedback }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Orchestrator] Reject failed:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async orchestratorPause() {
|
||||
try {
|
||||
await fetch('/api/orchestrator/pause', { method: 'POST' });
|
||||
} catch (err) {
|
||||
console.error('[Orchestrator] Pause failed:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async orchestratorResume() {
|
||||
try {
|
||||
await fetch('/api/orchestrator/resume', { method: 'POST' });
|
||||
} catch (err) {
|
||||
console.error('[Orchestrator] Resume failed:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async orchestratorStop() {
|
||||
try {
|
||||
await fetch('/api/orchestrator/stop', { method: 'POST' });
|
||||
this.orchestratorState = { state: 'idle' };
|
||||
this.renderOrchestratorPanel();
|
||||
} catch (err) {
|
||||
console.error('[Orchestrator] Stop failed:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async orchestratorSkipPhase(phaseId) {
|
||||
try {
|
||||
await fetch(`/api/orchestrator/phase/${phaseId}/skip`, { method: 'POST' });
|
||||
} catch (err) {
|
||||
console.error('[Orchestrator] Skip failed:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async orchestratorRetryPhase(phaseId) {
|
||||
try {
|
||||
await fetch(`/api/orchestrator/phase/${phaseId}/retry`, { method: 'POST' });
|
||||
} catch (err) {
|
||||
console.error('[Orchestrator] Retry failed:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async refreshOrchestratorStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/orchestrator/status');
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
this.orchestratorState = data;
|
||||
this.renderOrchestratorPanel();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Orchestrator] Status fetch failed:', err);
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Rendering
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
renderOrchestratorPanel() {
|
||||
const panel = document.getElementById('orchestratorPanel');
|
||||
if (!panel) return;
|
||||
|
||||
const state = this.orchestratorState?.state || 'idle';
|
||||
const plan = this.orchestratorState?.plan;
|
||||
|
||||
// Update badge
|
||||
const badge = document.getElementById('orchestratorStateBadge');
|
||||
if (badge) {
|
||||
badge.textContent = state;
|
||||
badge.style.background = ORCH_STATE_COLORS[state] || '#6b7280';
|
||||
}
|
||||
|
||||
// Update action buttons
|
||||
const actions = document.getElementById('orchestratorActions');
|
||||
if (actions) {
|
||||
actions.innerHTML = this._renderOrchestratorActions(state);
|
||||
}
|
||||
|
||||
// Update body
|
||||
const body = document.getElementById('orchestratorBody');
|
||||
if (body) {
|
||||
body.innerHTML = this._renderOrchestratorBody(state, plan);
|
||||
}
|
||||
},
|
||||
|
||||
_renderOrchestratorActions(state) {
|
||||
const btn = (label, onclick, cls = '') =>
|
||||
`<button class="orch-btn ${cls}" onclick="${onclick}">${label}</button>`;
|
||||
|
||||
switch (state) {
|
||||
case 'idle':
|
||||
case 'completed':
|
||||
case 'failed':
|
||||
return btn('New Goal', 'app.promptOrchestratorGoal()', 'orch-btn-primary');
|
||||
case 'planning':
|
||||
return btn('Cancel', 'app.orchestratorStop()', 'orch-btn-danger');
|
||||
case 'approval':
|
||||
return [
|
||||
btn('Approve', 'app.orchestratorApprove()', 'orch-btn-primary'),
|
||||
btn('Reject', 'app.promptOrchestratorReject()', 'orch-btn-warn'),
|
||||
btn('Cancel', 'app.orchestratorStop()', 'orch-btn-danger'),
|
||||
].join('');
|
||||
case 'executing':
|
||||
case 'verifying':
|
||||
case 'replanning':
|
||||
return [
|
||||
btn('Pause', 'app.orchestratorPause()'),
|
||||
btn('Stop', 'app.orchestratorStop()', 'orch-btn-danger'),
|
||||
].join('');
|
||||
case 'paused':
|
||||
return [
|
||||
btn('Resume', 'app.orchestratorResume()', 'orch-btn-primary'),
|
||||
btn('Stop', 'app.orchestratorStop()', 'orch-btn-danger'),
|
||||
].join('');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
},
|
||||
|
||||
_renderOrchestratorBody(state, plan) {
|
||||
if (state === 'idle' && !plan) {
|
||||
return '<div class="orch-empty">No orchestration active. Click "New Goal" to start.</div>';
|
||||
}
|
||||
|
||||
if (state === 'planning') {
|
||||
return '<div class="orch-planning"><div class="orch-spinner"></div>Generating plan...</div>';
|
||||
}
|
||||
|
||||
if (!plan) return '';
|
||||
|
||||
const parts = [];
|
||||
|
||||
// Goal
|
||||
parts.push(`<div class="orch-goal"><strong>Goal:</strong> ${escapeHtml(plan.goal.slice(0, 200))}</div>`);
|
||||
|
||||
// Progress summary
|
||||
const completed = plan.phases.filter(p => p.status === 'passed' || p.status === 'skipped').length;
|
||||
const total = plan.phases.length;
|
||||
const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
parts.push(`<div class="orch-progress-bar"><div class="orch-progress-fill" style="width:${pct}%"></div><span>${completed}/${total} phases</span></div>`);
|
||||
|
||||
// Phase list
|
||||
parts.push('<div class="orch-phases">');
|
||||
for (const phase of plan.phases) {
|
||||
parts.push(this._renderOrchestratorPhase(phase, state));
|
||||
}
|
||||
parts.push('</div>');
|
||||
|
||||
// Stats (if completed/failed)
|
||||
if (state === 'completed' || state === 'failed') {
|
||||
const stats = this.orchestratorState?.stats;
|
||||
if (stats) {
|
||||
parts.push(this._renderOrchestratorStats(stats));
|
||||
}
|
||||
if (this.orchestratorState?.lastError) {
|
||||
parts.push(`<div class="orch-error">Error: ${escapeHtml(this.orchestratorState.lastError)}</div>`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('');
|
||||
},
|
||||
|
||||
_renderOrchestratorPhase(phase, orchState) {
|
||||
const icon = ORCH_PHASE_STATUS_ICONS[phase.status] || '\u25cb';
|
||||
const isActive = phase.status === 'executing';
|
||||
const cls = `orch-phase ${isActive ? 'orch-phase-active' : ''} orch-phase-${phase.status}`;
|
||||
|
||||
let actions = '';
|
||||
if (orchState === 'executing' || orchState === 'failed') {
|
||||
if (phase.status === 'pending') {
|
||||
actions += `<button class="orch-phase-btn" onclick="app.orchestratorSkipPhase('${phase.id}')" title="Skip">skip</button>`;
|
||||
}
|
||||
if (phase.status === 'failed') {
|
||||
actions += `<button class="orch-phase-btn" onclick="app.orchestratorRetryPhase('${phase.id}')" title="Retry">retry</button>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Task summary
|
||||
const tasksDone = phase.tasks.filter(t => t.status === 'completed').length;
|
||||
const tasksFailed = phase.tasks.filter(t => t.status === 'failed').length;
|
||||
const tasksTotal = phase.tasks.length;
|
||||
const taskSummary = `${tasksDone}/${tasksTotal}${tasksFailed > 0 ? ` (${tasksFailed} failed)` : ''}`;
|
||||
|
||||
// Duration
|
||||
let duration = '';
|
||||
if (phase.durationMs) {
|
||||
const secs = Math.round(phase.durationMs / 1000);
|
||||
duration = secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s`;
|
||||
}
|
||||
|
||||
let html = `<div class="${cls}">`;
|
||||
html += `<div class="orch-phase-header">`;
|
||||
html += `<span class="orch-phase-icon">${icon}</span>`;
|
||||
html += `<span class="orch-phase-name">${escapeHtml(phase.name)}</span>`;
|
||||
html += `<span class="orch-phase-tasks">${taskSummary}</span>`;
|
||||
if (duration) html += `<span class="orch-phase-duration">${duration}</span>`;
|
||||
if (actions) html += `<span class="orch-phase-actions">${actions}</span>`;
|
||||
html += `</div>`;
|
||||
|
||||
// Expanded task list for active phase
|
||||
if (isActive || phase.status === 'failed') {
|
||||
html += '<div class="orch-phase-tasks-list">';
|
||||
for (const task of phase.tasks) {
|
||||
const taskIcon = ORCH_PHASE_STATUS_ICONS[task.status] || '\u25cb';
|
||||
const taskCls = `orch-task orch-task-${task.status}`;
|
||||
html += `<div class="${taskCls}"><span class="orch-task-icon">${taskIcon}</span>`;
|
||||
html += `<span class="orch-task-prompt">${escapeHtml(task.prompt.slice(0, 100))}</span>`;
|
||||
if (task.error) html += `<span class="orch-task-error">${escapeHtml(task.error.slice(0, 80))}</span>`;
|
||||
html += '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// Verification result
|
||||
if (phase._lastVerification) {
|
||||
const v = phase._lastVerification;
|
||||
const vCls = v.passed ? 'orch-verify-pass' : 'orch-verify-fail';
|
||||
html += `<div class="${vCls}">${v.passed ? 'Verified' : 'Failed'}: ${escapeHtml(v.summary || '')}</div>`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
},
|
||||
|
||||
_renderOrchestratorStats(stats) {
|
||||
return `<div class="orch-stats">
|
||||
<span>Phases: ${stats.phasesCompleted} done, ${stats.phasesFailed} failed</span>
|
||||
<span>Tasks: ${stats.totalTasksCompleted} done, ${stats.totalTasksFailed} failed</span>
|
||||
${stats.replanCount > 0 ? `<span>Replans: ${stats.replanCount}</span>` : ''}
|
||||
${stats.totalDurationMs ? `<span>Duration: ${Math.round(stats.totalDurationMs / 60000)}m</span>` : ''}
|
||||
</div>`;
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// User prompts
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
promptOrchestratorGoal() {
|
||||
const goal = prompt('Enter your goal for the orchestrator:');
|
||||
if (goal && goal.trim()) {
|
||||
this.orchestratorStart(goal.trim());
|
||||
}
|
||||
},
|
||||
|
||||
promptOrchestratorReject() {
|
||||
const feedback = prompt('Feedback on the plan (what should change?):');
|
||||
if (feedback && feedback.trim()) {
|
||||
this.orchestratorReject(feedback.trim());
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -7540,3 +7540,222 @@ kbd {
|
||||
color: #495057;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
Orchestrator Panel
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.orchestrator-panel {
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
contain: layout style paint;
|
||||
}
|
||||
|
||||
.orchestrator-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.orchestrator-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.orchestrator-state-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.orchestrator-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.orchestrator-close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
.orchestrator-close-btn:hover { color: var(--text-primary); }
|
||||
|
||||
.orch-btn {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.orch-btn:hover { background: var(--bg-hover); }
|
||||
.orch-btn-primary { background: #3b82f6; color: #fff; border-color: #3b82f6; }
|
||||
.orch-btn-primary:hover { background: #2563eb; }
|
||||
.orch-btn-warn { background: #f59e0b; color: #000; border-color: #f59e0b; }
|
||||
.orch-btn-warn:hover { background: #d97706; }
|
||||
.orch-btn-danger { background: #ef4444; color: #fff; border-color: #ef4444; }
|
||||
.orch-btn-danger:hover { background: #dc2626; }
|
||||
|
||||
.orchestrator-body {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.orch-empty {
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.orch-planning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #f59e0b;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.orch-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid #f59e0b33;
|
||||
border-top-color: #f59e0b;
|
||||
border-radius: 50%;
|
||||
animation: orch-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes orch-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.orch-goal {
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.orch-progress-bar {
|
||||
position: relative;
|
||||
height: 18px;
|
||||
background: var(--bg-input);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.orch-progress-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #3b82f6, #22c55e);
|
||||
transition: width 0.3s ease;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.orch-progress-bar span {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.orch-phases {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.orch-phase {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.orch-phase-active { border-color: #3b82f6; }
|
||||
.orch-phase-passed { opacity: 0.7; }
|
||||
.orch-phase-skipped { opacity: 0.5; }
|
||||
.orch-phase-failed { border-color: #ef4444; }
|
||||
|
||||
.orch-phase-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.orch-phase-icon { width: 14px; text-align: center; }
|
||||
.orch-phase-name { flex: 1; color: var(--text-primary); font-weight: 500; }
|
||||
.orch-phase-tasks { color: var(--text-secondary); font-size: 0.65rem; }
|
||||
.orch-phase-duration { color: var(--text-secondary); font-size: 0.65rem; }
|
||||
.orch-phase-actions { display: flex; gap: 2px; }
|
||||
|
||||
.orch-phase-btn {
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
.orch-phase-btn:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
|
||||
.orch-phase-tasks-list {
|
||||
padding: 4px 8px 4px 26px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.orch-task {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
padding: 2px 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.orch-task-icon { width: 12px; text-align: center; flex-shrink: 0; margin-top: 1px; }
|
||||
.orch-task-prompt { color: var(--text-primary); word-break: break-word; }
|
||||
.orch-task-error { color: #ef4444; font-size: 0.6rem; margin-left: auto; }
|
||||
.orch-task-completed .orch-task-prompt { color: var(--text-secondary); }
|
||||
.orch-task-failed .orch-task-icon { color: #ef4444; }
|
||||
|
||||
.orch-verify-pass { color: #22c55e; padding: 2px 8px; font-size: 0.65rem; }
|
||||
.orch-verify-fail { color: #ef4444; padding: 2px 8px; font-size: 0.65rem; }
|
||||
|
||||
.orch-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 6px 0;
|
||||
margin-top: 6px;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.orch-error {
|
||||
color: #ef4444;
|
||||
padding: 4px 0;
|
||||
margin-top: 4px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -580,3 +580,31 @@ export type RespawnEnableInput = z.infer<typeof RespawnEnableSchema>;
|
||||
export type PushSubscribeInput = z.infer<typeof PushSubscribeSchema>;
|
||||
export type PushPreferencesUpdateInput = z.infer<typeof PushPreferencesUpdateSchema>;
|
||||
export type RalphLoopStartInput = z.infer<typeof RalphLoopStartSchema>;
|
||||
|
||||
// ========== Orchestrator Loop ==========
|
||||
|
||||
/** POST /api/orchestrator/start */
|
||||
export const OrchestratorStartSchema = z.object({
|
||||
goal: z.string().min(1).max(100000),
|
||||
config: z
|
||||
.object({
|
||||
plannerModel: z.string().max(100).optional(),
|
||||
researchEnabled: z.boolean().optional(),
|
||||
autoApprove: z.boolean().optional(),
|
||||
maxPhaseRetries: z.number().int().min(1).max(10).optional(),
|
||||
phaseTimeoutMs: z.number().int().min(60000).max(7200000).optional(),
|
||||
enableTeamAgents: z.boolean().optional(),
|
||||
maxParallelSessions: z.number().int().min(1).max(10).optional(),
|
||||
verificationMode: z.enum(['strict', 'moderate', 'lenient']).optional(),
|
||||
compactBetweenPhases: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/** POST /api/orchestrator/reject */
|
||||
export const OrchestratorRejectSchema = z.object({
|
||||
feedback: z.string().min(1).max(10000),
|
||||
});
|
||||
|
||||
export type OrchestratorStartInput = z.infer<typeof OrchestratorStartSchema>;
|
||||
export type OrchestratorRejectInput = z.infer<typeof OrchestratorRejectSchema>;
|
||||
|
||||
@@ -70,6 +70,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { createRequire } from 'node:module';
|
||||
import { RunSummaryTracker } from '../run-summary.js';
|
||||
import { PlanOrchestrator } from '../plan-orchestrator.js';
|
||||
import { OrchestratorLoop } from '../orchestrator-loop.js';
|
||||
import { getLifecycleLog } from '../session-lifecycle-log.js';
|
||||
import { PushSubscriptionStore } from '../push-store.js';
|
||||
import webpush from 'web-push';
|
||||
@@ -104,6 +105,7 @@ import {
|
||||
registerRespawnRoutes,
|
||||
registerRalphRoutes,
|
||||
registerPlanRoutes,
|
||||
registerOrchestratorRoutes,
|
||||
registerWsRoutes,
|
||||
} from './routes/index.js';
|
||||
|
||||
@@ -280,6 +282,7 @@ export class WebServer extends EventEmitter {
|
||||
private qrAuthFailures: StaleExpirationMap<string, number> | null = null;
|
||||
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
|
||||
private teamWatcher: TeamWatcher = new TeamWatcher();
|
||||
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
|
||||
private teamWatcherHandlers: {
|
||||
teamCreated: (config: unknown) => void;
|
||||
teamUpdated: (config: unknown) => void;
|
||||
@@ -537,6 +540,9 @@ export class WebServer extends EventEmitter {
|
||||
// AuthPort
|
||||
authSessions: this.authSessions,
|
||||
qrAuthFailures: this.qrAuthFailures,
|
||||
// OrchestratorPort
|
||||
orchestratorLoop: this._orchestratorLoop,
|
||||
initOrchestratorLoop: () => this.initOrchestratorLoop(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -705,6 +711,7 @@ export class WebServer extends EventEmitter {
|
||||
registerRespawnRoutes(this.app, ctx);
|
||||
registerRalphRoutes(this.app, ctx);
|
||||
registerPlanRoutes(this.app, ctx);
|
||||
registerOrchestratorRoutes(this.app, ctx);
|
||||
registerWsRoutes(this.app, ctx);
|
||||
}
|
||||
|
||||
@@ -2705,6 +2712,13 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
private initOrchestratorLoop(): import('../orchestrator-loop.js').OrchestratorLoop {
|
||||
if (this._orchestratorLoop) return this._orchestratorLoop;
|
||||
|
||||
this._orchestratorLoop = new OrchestratorLoop(this.mux, process.cwd());
|
||||
return this._orchestratorLoop;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
getLifecycleLog().log({ event: 'server_stopped', sessionId: '*' });
|
||||
// Set stopping flag to prevent new timer creation during shutdown
|
||||
@@ -2767,6 +2781,13 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
this.respawnControllers.clear();
|
||||
|
||||
// Stop orchestrator loop if running
|
||||
if (this._orchestratorLoop) {
|
||||
await this._orchestratorLoop.stop();
|
||||
this._orchestratorLoop.destroy();
|
||||
this._orchestratorLoop = null;
|
||||
}
|
||||
|
||||
// Stop all scheduled runs first (they have their own session cleanup)
|
||||
await Promise.allSettled(Array.from(this.scheduledRuns.keys()).map((id) => this.stopScheduledRun(id)));
|
||||
|
||||
|
||||
+39
-1
@@ -5,7 +5,7 @@
|
||||
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
|
||||
* Both files MUST be kept in sync.
|
||||
*
|
||||
* ~106 event constants organized by category:
|
||||
* ~117 event constants organized by category:
|
||||
* - **Core** (1): init
|
||||
* - **Session lifecycle** (17): created, updated, deleted, terminal, idle, working, ...
|
||||
* - **Session: Ralph** (6): ralphLoopUpdate, todoUpdate, completionDetected, ...
|
||||
@@ -292,6 +292,31 @@ export const HookTeammateIdle = 'hook:teammate_idle' as const;
|
||||
/** Claude Code hook: teammate task completed. */
|
||||
export const HookTaskCompleted = 'hook:task_completed' as const;
|
||||
|
||||
// ─── Orchestrator ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Orchestrator state machine transitioned. */
|
||||
export const OrchestratorStateChanged = 'orchestrator:stateChanged' as const;
|
||||
/** Orchestrator plan generated and ready for approval. */
|
||||
export const OrchestratorPlanReady = 'orchestrator:planReady' as const;
|
||||
/** Orchestrator phase started executing. */
|
||||
export const OrchestratorPhaseStarted = 'orchestrator:phaseStarted' as const;
|
||||
/** Orchestrator phase completed successfully. */
|
||||
export const OrchestratorPhaseCompleted = 'orchestrator:phaseCompleted' as const;
|
||||
/** Orchestrator phase failed. */
|
||||
export const OrchestratorPhaseFailed = 'orchestrator:phaseFailed' as const;
|
||||
/** Orchestrator verification result for a phase. */
|
||||
export const OrchestratorVerification = 'orchestrator:verification' as const;
|
||||
/** Orchestrator task assigned to session. */
|
||||
export const OrchestratorTaskAssigned = 'orchestrator:taskAssigned' as const;
|
||||
/** Orchestrator task completed. */
|
||||
export const OrchestratorTaskCompleted = 'orchestrator:taskCompleted' as const;
|
||||
/** Orchestrator task failed. */
|
||||
export const OrchestratorTaskFailed = 'orchestrator:taskFailed' as const;
|
||||
/** All orchestrator phases completed successfully. */
|
||||
export const OrchestratorCompleted = 'orchestrator:completed' as const;
|
||||
/** Orchestrator error. */
|
||||
export const OrchestratorError = 'orchestrator:error' as const;
|
||||
|
||||
// ─── Cases ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** New case directory created. */
|
||||
@@ -441,6 +466,19 @@ export const SseEvent = {
|
||||
HookTeammateIdle,
|
||||
HookTaskCompleted,
|
||||
|
||||
// Orchestrator
|
||||
OrchestratorStateChanged,
|
||||
OrchestratorPlanReady,
|
||||
OrchestratorPhaseStarted,
|
||||
OrchestratorPhaseCompleted,
|
||||
OrchestratorPhaseFailed,
|
||||
OrchestratorVerification,
|
||||
OrchestratorTaskAssigned,
|
||||
OrchestratorTaskCompleted,
|
||||
OrchestratorTaskFailed,
|
||||
OrchestratorCompleted,
|
||||
OrchestratorError,
|
||||
|
||||
// Cases
|
||||
CaseCreated,
|
||||
CaseLinked,
|
||||
|
||||
Reference in New Issue
Block a user