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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-03-21 07:20:18 +01:00
co-authored by Claude Opus 4.6
parent 497ca4891a
commit 61b5ec095c
27 changed files with 4718 additions and 1 deletions
+367
View File
@@ -0,0 +1,367 @@
# Orchestrator Loop — Architecture & Data Flow
> Technical architecture document. Not for GitHub.
## System Overview
```
┌─────────────────────────────────────────────────────────────────────┐
│ CODEMAN WEB UI │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Orchestrator Dashboard │ │
│ │ [Goal Input] [Plan View] [Phase Progress] [Agent Activity] │ │
│ └───────────────────────────┬──────────────────────────────────┘ │
│ │ SSE Events │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Orchestrator API Routes (/api/orchestrator/*) │ │
│ └───────────────────────────┬──────────────────────────────────┘ │
└───────────────────────────────┼─────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────────┐
│ ORCHESTRATOR LOOP │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ Orchestrator │ │ Orchestrator │ │ Orchestrator │ │
│ │ Planner │ │ Loop (state │ │ Verifier │ │
│ │ │ │ machine) │ │ │ │
│ │ • Research │◄──►│ • Phase mgmt │◄──►│ • Test runner │ │
│ │ • Plan gen │ │ • Task queue │ │ • AI review │ │
│ │ • Phasing │ │ • Event loop │ │ • Output checks │ │
│ └──────┬───────┘ └──────┬───────┘ └──────────┬───────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ EXISTING CODEMAN INFRASTRUCTURE │ │
│ │ │ │
│ │ SessionManager ←→ Sessions ←→ PTY (Claude CLI) │ │
│ │ ↑ ↑ ↑ │ │
│ │ │ │ │ │ │
│ │ TaskQueue RalphTracker RespawnController │ │
│ │ StateStore HooksConfig TeamWatcher │ │
│ │ Auto-Ops SubagentWatcher SSE Broadcast │ │
│ └──────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
## Data Flow: Complete Lifecycle
### 1. User Submits Goal
```
User → POST /api/orchestrator/start { goal: "Build a REST API...", config: {...} }
→ OrchestratorLoop.start(goal)
→ state = PLANNING
→ emit('stateChanged', 'planning')
→ SSE: orchestrator:stateChanged
```
### 2. Planning Phase
```
OrchestratorPlanner.generatePlan(goal)
→ PlanOrchestrator.generateDetailedPlan(goal)
→ [Research Agent] → enriched task description
→ [Planner Agent] → PlanItem[]
→ groupIntoPhases(planItems)
→ topological sort by dependencies
→ group into layers
→ assign team strategies
→ OrchestratorPlan { phases: [...] }
→ state = APPROVAL
→ emit('planReady', plan)
→ SSE: orchestrator:planReady
```
### 3. User Approves Plan
```
User → POST /api/orchestrator/approve
→ OrchestratorLoop.approvePlan()
→ state = EXECUTING
→ executePhase(phases[0])
```
### 4. Phase Execution
```
executePhase(phase)
→ For each task in phase:
→ Convert to CreateTaskOptions
→ Add to TaskQueue with completion phrase "PHASE_{N}_TASK_{M}_DONE"
→ If phase.teamStrategy.type === 'team':
→ Start session with AGENT_TEAMS enabled
→ Send team orchestration prompt to lead
→ Else:
→ Assign tasks to available sessions (same as RalphLoop)
→ Listen for task completion events:
→ TaskQueue emits taskCompleted
→ Check: all phase tasks done?
→ Yes → state = VERIFYING → verifyPhase(phase)
→ No → wait for more completions
```
### 5. Verification
```
verifyPhase(phase)
→ OrchestratorVerifier.verify(phase, session)
→ Run test commands via session
→ Check file existence
→ AI review (optional)
→ If passed:
→ phase.status = 'passed'
→ emit('phaseCompleted', phase)
→ If more phases: executePhase(nextPhase)
→ If last phase: state = COMPLETED
→ If failed:
→ phase.attempts++
→ If attempts < maxAttempts:
→ state = REPLANNING
→ Generate recovery tasks
→ state = EXECUTING (retry)
→ Else:
→ state = FAILED
→ emit('phaseFailed', phase, reason)
```
### 6. Context Management Between Phases
```
After phase completion:
→ If config.compactBetweenPhases:
→ session.sendInput('/compact')
→ Wait for compact to complete
→ If config.respawnBetweenMilestones && phase is a milestone:
→ Save orchestrator state to StateStore
→ Respawn session (kill + recreate)
→ Send resume prompt with phase context
```
## File Layout
```
src/
├── orchestrator-loop.ts # Main state machine (~400 lines)
├── orchestrator-planner.ts # Plan generation + phase grouping (~300 lines)
├── orchestrator-verifier.ts # Phase verification (~200 lines)
├── types/
│ └── orchestrator.ts # All orchestrator types (~150 lines)
├── prompts/
│ └── orchestrator.ts # Prompt templates (~200 lines)
├── web/
│ ├── routes/
│ │ └── orchestrator-routes.ts # API endpoints (~250 lines)
│ └── public/
│ └── orchestrator-ui.js # Frontend panel (~500 lines)
```
## Integration Points with Existing Code
### StateStore (`src/state-store.ts`)
```typescript
// Add to AppState interface
orchestrator?: OrchestratorPersistState;
// Add methods
getOrchestratorState(): OrchestratorPersistState;
setOrchestratorState(state: Partial<OrchestratorPersistState>): void;
```
### SSE Events (`src/web/sse-events.ts`)
```typescript
// Add ~8 new events
export const SseEvent = {
// ... existing
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:verificationResult',
ORCHESTRATOR_COMPLETED: 'orchestrator:completed',
ORCHESTRATOR_ERROR: 'orchestrator:error',
} as const;
```
### Frontend Constants (`src/web/public/constants.js`)
```javascript
// Mirror SSE events
SSE_EVENTS.ORCHESTRATOR_STATE_CHANGED = 'orchestrator:stateChanged';
// ... etc
```
### Route Registration (`src/web/routes/index.ts`)
```typescript
import { registerOrchestratorRoutes } from './orchestrator-routes.js';
// Add to barrel export
```
### Server (`src/web/server.ts`)
```typescript
// Initialize OrchestratorLoop alongside RalphLoop
const orchestratorLoop = new OrchestratorLoop(config);
// Register routes
registerOrchestratorRoutes(app, { ...ctx, orchestrator: orchestratorLoop });
```
### Port Interface (`src/web/ports/`)
```typescript
// New port
export interface OrchestratorPort {
orchestrator: OrchestratorLoop;
}
```
## Prompt Flow Through System
The key insight is how prompts flow from Orchestrator → Session → Claude:
```
OrchestratorLoop decides to execute Phase 3, Task 2
│
▼
Converts OrchestratorTask to CreateTaskOptions:
{
prompt: "Implement the rate limiter middleware. Read src/middleware/auth.ts
for the pattern. Add to src/middleware/rate-limiter.ts. Must export
a Fastify plugin. When done: <promise>PHASE_3_TASK_2_DONE</promise>",
priority: 100,
dependencies: ["phase-3-task-1"], // Must finish auth middleware first
completionPhrase: "PHASE_3_TASK_2_DONE",
timeoutMs: 600000 // 10 minutes
}
│
▼
TaskQueue.addTask(options)
│
▼
RalphLoop.tick() → assignTasks() // OR OrchestratorLoop does its own assignment
│
▼
session.sendInput(task.prompt)
│
▼
writeViaMux() → tmux send-keys -l "prompt..." + Enter
│
▼
Claude CLI receives prompt, executes, outputs results
│
▼
RalphTracker.processData() → detects "PHASE_3_TASK_2_DONE"
│
▼
emit('completionDetected') → OrchestratorLoop.handleTaskCompleted()
│
▼
Check: all tasks in Phase 3 done? → If yes → verifyPhase(phase3)
```
## Team Agent Flow (When Enabled)
```
Phase has teamStrategy.type === 'team'
│
▼
OrchestratorLoop creates/reuses a session with:
env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' }
│
▼
Sends team orchestration prompt:
"You're the team lead for Phase 3: Core Implementation.
Your team should work on these tasks in parallel:
1. Rate limiter middleware (teammate 1)
2. Error handling middleware (teammate 2)
3. Validation layer (teammate 3)
Context files to read first: [...]
Each teammate should output their task's completion phrase when done.
When ALL tasks are complete, output: <promise>PHASE_3_COMPLETE</promise>"
│
▼
Claude Code team-lead spawns teammates
│
▼
TeamWatcher detects new team in ~/.claude/teams/
→ Matches to session via leadSessionId
→ Tracks teammate activity
│
▼
Teammates work in parallel (in-process threads)
│
▼
hook: teammate_idle → POST /api/hook-event
→ OrchestratorLoop notes teammate finished
│
▼
hook: task_completed → POST /api/hook-event
→ Or: RalphTracker detects PHASE_3_COMPLETE
→ OrchestratorLoop → phase complete → verify
```
## Error Recovery Strategy
```
Task fails (timeout, error, session crash)
│
├─ Task-level retry (up to 2 retries per task)
│ → Reset task to pending
│ → Re-queue with modified prompt: "Previous attempt failed: {error}. Try again..."
│
├─ Phase-level retry (up to 3 retries per phase)
│ → Respawn session (fresh context)
│ → Re-execute entire phase with learnings from failure
│ → Modified prompt includes what went wrong
│
└─ Orchestration-level failure
→ All retries exhausted
→ state = FAILED
→ Notify user with detailed failure report
→ User can: modify plan → retry, skip phase → continue, or stop
```
## Interaction with Ralph Loop
Ralph Loop and Orchestrator Loop are **mutually exclusive** on the same sessions:
```
if (orchestratorLoop.isRunning()) {
// Orchestrator controls task assignment
// Ralph Loop should not interfere
// Respawn Controller uses 'orchestrator' preset
}
if (ralphLoop.isRunning()) {
// Ralph controls task assignment
// Orchestrator should not start
}
```
The Orchestrator can optionally USE the Ralph Loop internally for phase execution (delegate phase tasks to Ralph's queue), or manage task assignment directly. Decision: **manage directly** — gives more control over phase boundaries and verification timing.
## Summary of What Touches What
| Existing File | Change |
|---|---|
| `src/types/index.ts` | Export orchestrator types |
| `src/state-store.ts` | Add orchestrator state persistence |
| `src/web/sse-events.ts` | Add ~8 orchestrator events |
| `src/web/routes/index.ts` | Register orchestrator routes |
| `src/web/server.ts` | Initialize OrchestratorLoop |
| `src/web/public/constants.js` | Mirror SSE events |
| `src/web/public/app.js` | Add orchestrator event listeners, panel toggle |
| `src/web/route-helpers.ts` | Add 'orchestrator' respawn preset |
| New File | Purpose |
|---|---|
| `src/orchestrator-loop.ts` | Core state machine |
| `src/orchestrator-planner.ts` | Plan generation + phasing |
| `src/orchestrator-verifier.ts` | Phase verification |
| `src/types/orchestrator.ts` | Type definitions |
| `src/prompts/orchestrator.ts` | Prompt templates |
| `src/web/routes/orchestrator-routes.ts` | API endpoints |
| `src/web/public/orchestrator-ui.js` | Frontend panel |
| `src/web/ports/orchestrator-port.ts` | Port interface |
+633
View File
@@ -0,0 +1,633 @@
# Orchestrator Loop — Detailed Implementation Plan (v2)
> Internal research/planning document. Not for GitHub.
## Vision
The **Orchestrator Loop** is a new autonomous execution mode that transforms high-level user goals into phased, verified, team-coordinated implementations. Unlike Ralph Loop (flat task queue → idle sessions), the Orchestrator manages the full lifecycle: **plan → approve → execute → verify → adapt → complete**.
```
USER: "Add OAuth2 login with Google/GitHub, role-based access control, and API key management"
ORCHESTRATOR:
Phase 1: Research & Setup ✅ (3m) — scaffold, deps, config
Phase 2: Auth Core ✅ (8m) — OAuth2 flow, session mgmt
Phase 3: Provider Integration 🔄 (12m) — Google + GitHub (parallel via team agents)
Phase 4: RBAC ⏳ — roles, permissions, middleware
Phase 5: API Keys ⏳ — generation, validation, rate limits
Phase 6: Testing & Review ⏳ — integration tests, security review
Progress: ━━━━━━━━━━━━━━━━━━━━ 40% | Agents: 3 active | Time: 23m
```
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ OrchestratorLoop │
│ │
│ ┌────────────────┐ ┌────────────────┐ ┌──────────────────┐ │
│ │ Orchestrator │ │ Orchestrator │ │ Orchestrator │ │
│ │ Planner │ │ Executor │ │ Verifier │ │
│ │ │ │ │ │ │ │
│ │ PlanOrchestrator│ │ TaskQueue │ │ AI review │ │
│ │ + phase grouper│ │ SessionManager │ │ Test commands │ │
│ │ + team strategy│ │ Team prompts │ │ File checks │ │
│ └───────┬────────┘ └───────┬────────┘ └─────────┬────────┘ │
│ │ │ │ │
│ └───────────────────┼──────────────────────┘ │
│ │ │
│ ┌─────────▼─────────┐ │
│ │ Existing Codeman │ │
│ │ Infrastructure │ │
│ │ │ │
│ │ SessionManager │ │
│ │ TaskQueue │ │
│ │ RespawnController │ │
│ │ TeamWatcher │ │
│ │ PlanOrchestrator │ │
│ │ StateStore │ │
│ │ Hooks + SSE │ │
│ └────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
## State Machine
```
┌─────────┐
│ IDLE │
└────┬────┘
│ start(goal)
▼
┌─────────┐
┌────────│PLANNING │────────┐
│ fail └────┬────┘ │
▼ │ plan ready │ user cancels
┌────────┐ ▼ ▼
│ FAILED │ ┌─────────┐ ┌────────┐
└────────┘ │APPROVAL │ │ IDLE │
▲ └────┬────┘ └────────┘
│ │ approve
│ ▼
│ ┌──────────┐
│ ┌───►│EXECUTING │◄────────────────────┐
│ │ └────┬─────┘ │
│ │ │ all tasks in phase done │
│ │ ▼ │
│ │ ┌──────────┐ │
│ │ │VERIFYING │ │
│ │ └────┬─────┘ │
│ │ pass │ │ fail │
│ │ ▼ ▼ │
│ │ more ┌──────────┐ │
│ │ phases?│REPLANNING│── retry ────────┘
│ │ │ └────┬─────┘
│ │ │ │ max retries
│ │ │ ▼
│ │ │ ┌────────┐
│ └────┘ │ FAILED │
│ next └────────┘
│ phase
│ │
│ ▼
│ ┌───────────┐
└─│ COMPLETED │
└───────────┘
```
**States:** `idle` | `planning` | `approval` | `executing` | `verifying` | `replanning` | `completed` | `failed` | `paused`
Transitions are event-driven. The state machine is the single source of truth — all methods check `this.state` before acting.
## Type Definitions
### `src/types/orchestrator.ts`
```typescript
// ═══════════════════════════════════════════════════════════════
// State Machine
// ═══════════════════════════════════════════════════════════════
export type OrchestratorState =
| 'idle'
| 'planning'
| 'approval'
| 'executing'
| 'verifying'
| 'replanning'
| 'completed'
| 'failed'
| 'paused';
// ═══════════════════════════════════════════════════════════════
// Plan Structure
// ═══════════════════════════════════════════════════════════════
export interface OrchestratorPlan {
id: string;
goal: string;
createdAt: number;
phases: OrchestratorPhase[];
metadata: {
totalTasks: number;
estimatedComplexity: 'low' | 'medium' | 'high';
modelUsed: string;
planDurationMs: number;
};
}
export interface OrchestratorPhase {
id: string; // "phase-1", "phase-2"
name: string; // Human-readable name
description: string;
order: number;
status: PhaseStatus;
tasks: OrchestratorTask[];
verificationCriteria: string[];
testCommands: string[];
maxAttempts: number; // Default: 3
attempts: number; // Current attempt count
startedAt: number | null;
completedAt: number | null;
durationMs: number | null;
teamStrategy: TeamStrategy;
}
export type PhaseStatus =
| 'pending'
| 'executing'
| 'verifying'
| 'passed'
| 'failed'
| 'skipped';
export interface OrchestratorTask {
id: string; // "phase-1-task-1"
phaseId: string;
prompt: string; // Single-line prompt for Claude
status: 'pending' | 'running' | 'completed' | 'failed';
assignedSessionId: string | null;
queueTaskId: string | null; // Links to TaskQueue task
parallel: boolean; // Can run in parallel with sibling tasks
completionPhrase: string; // Unique phrase for completion detection
timeoutMs: number;
startedAt: number | null;
completedAt: number | null;
error: string | null;
retries: number;
}
// ═══════════════════════════════════════════════════════════════
// Team Strategy
// ═══════════════════════════════════════════════════════════════
export type TeamStrategy =
| { type: 'single' } // One session handles all
| { type: 'parallel'; maxSessions: number } // Multiple sessions
| { type: 'team'; config: TeamSetup } // Agent teams
export interface TeamSetup {
leadPrompt: string;
suggestedTeammates: string[]; // Role descriptions
maxTeammates: number;
}
// ═══════════════════════════════════════════════════════════════
// Verification
// ═══════════════════════════════════════════════════════════════
export interface VerificationResult {
passed: boolean;
checks: VerificationCheck[];
summary: string;
suggestions: string[]; // Recovery hints for replanning
}
export interface VerificationCheck {
type: 'test_command' | 'ai_review' | 'file_check';
description: string;
passed: boolean;
output?: string;
}
// ═══════════════════════════════════════════════════════════════
// Configuration
// ═══════════════════════════════════════════════════════════════
export interface OrchestratorConfig {
plannerModel: string; // Default: 'opus'
researchEnabled: boolean; // Default: true
autoApprove: boolean; // Default: false
maxPhaseRetries: number; // Default: 3
phaseTimeoutMs: number; // Default: 1800000 (30min)
enableTeamAgents: boolean; // Default: true
maxParallelSessions: number; // Default: 3
verificationMode: 'strict' | 'moderate' | 'lenient';
compactBetweenPhases: boolean; // Default: true
}
// ═══════════════════════════════════════════════════════════════
// Persistence (saved to ~/.codeman/state.json)
// ═══════════════════════════════════════════════════════════════
export interface OrchestratorPersistState {
state: OrchestratorState;
plan: OrchestratorPlan | null;
currentPhaseIndex: number;
startedAt: number | null;
completedAt: number | null;
config: OrchestratorConfig;
stats: OrchestratorStats;
}
export interface OrchestratorStats {
phasesCompleted: number;
phasesFailed: number;
totalTasksCompleted: number;
totalTasksFailed: number;
totalDurationMs: number;
replanCount: number;
}
```
## New Files (Implementation Order)
### Step 1: `src/types/orchestrator.ts` — Type definitions
All interfaces above. No dependencies. ~120 lines.
### Step 2: `src/orchestrator-planner.ts` — Plan generation + phase grouping
~300 lines. Wraps existing PlanOrchestrator.
```typescript
/**
* @fileoverview Orchestrator plan generation — converts goals into phased plans.
*
* Uses PlanOrchestrator for AI plan generation, then groups PlanItems into
* sequential phases with team strategies and verification criteria.
*
* @module orchestrator-planner
*/
export class OrchestratorPlanner {
constructor(mux: TerminalMultiplexer, workingDir: string, config: OrchestratorConfig);
/** Generate plan from goal. Uses PlanOrchestrator internally. */
async generatePlan(goal: string, onProgress?: ProgressCallback): Promise<OrchestratorPlan>;
/** Cancel in-progress plan generation. */
async cancel(): Promise<void>;
// Internal
private groupIntoPhases(items: PlanItem[], goal: string): OrchestratorPhase[];
private assignTeamStrategies(phases: OrchestratorPhase[]): void;
private generateCompletionPhrases(plan: OrchestratorPlan): void;
}
```
**Phase grouping algorithm:**
1. Topological sort by `PlanItem.dependencies`
2. Group into dependency layers (Kahn's algorithm)
3. Within each layer, sub-group by `tddPhase` (setup → test → impl → verify → review)
4. Merge adjacent small phases (< 2 tasks) if they share the same tddPhase
5. Assign team strategies:
- 1-2 tasks → `{ type: 'single' }`
- 3+ independent tasks → `{ type: 'parallel', maxSessions: Math.min(taskCount, config.maxParallelSessions) }`
- 4+ tasks with high complexity → `{ type: 'team', config: { ... } }`
6. Generate unique completion phrases per task: `ORCH_P{phaseOrder}_T{taskIndex}`
### Step 3: `src/orchestrator-verifier.ts` — Phase verification
~200 lines.
```typescript
/**
* @fileoverview Orchestrator phase verification.
*
* Runs verification checks after each phase completes:
* test commands, AI review, and file existence checks.
*
* @module orchestrator-verifier
*/
export class OrchestratorVerifier {
constructor(config: OrchestratorConfig);
/** Run all verification checks for a completed phase. */
async verifyPhase(
phase: OrchestratorPhase,
session: Session,
mode: 'strict' | 'moderate' | 'lenient'
): Promise<VerificationResult>;
// Verification strategies
private async runTestCommands(commands: string[], session: Session): Promise<VerificationCheck[]>;
private async aiReview(phase: OrchestratorPhase, session: Session): Promise<VerificationCheck>;
}
```
**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
**AI review prompt (sent as a task to the session):**
```
Review Phase "{phase.name}" completion. Check:
1. Expected functionality works
2. No obvious regressions
3. Code quality is acceptable
Criteria: {phase.verificationCriteria.join('\n')}
If ALL criteria are met, respond: ORCH_VERIFY_PASS
If ANY criteria fail, respond: ORCH_VERIFY_FAIL and explain what failed.
```
### Step 4: `src/orchestrator-loop.ts` — Core state machine
~500 lines. Main orchestrator engine.
```typescript
/**
* @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.
*
* @module orchestrator-loop
*/
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;
}
export class OrchestratorLoop extends EventEmitter {
private state: OrchestratorState = 'idle';
private plan: OrchestratorPlan | null = null;
private currentPhaseIndex = 0;
private config: OrchestratorConfig;
private planner: OrchestratorPlanner;
private verifier: OrchestratorVerifier;
private sessionManager: SessionManager;
private taskQueue: TaskQueue;
private store: StateStore;
private stats: OrchestratorStats;
private cleanup: CleanupManager;
private pausedState: OrchestratorState | null = null; // State before pause
// ── Lifecycle ──────────────────────────────────────────────
constructor(mux: TerminalMultiplexer, workingDir: string, config?: Partial<OrchestratorConfig>);
/** Start orchestration with a goal. Transitions: idle → planning */
async start(goal: string): Promise<void>;
/** Approve the generated plan. Transitions: approval → executing */
async approve(): Promise<void>;
/** Reject plan with feedback. Transitions: approval → planning (regenerate) */
async reject(feedback: string): Promise<void>;
/** Pause execution. Saves current state. */
pause(): void;
/** Resume from pause. */
resume(): void;
/** Stop everything and clean up. → idle */
async stop(): Promise<void>;
/** Skip current phase. → executing (next phase) or completed */
async skipPhase(phaseId: string): Promise<void>;
/** Retry a failed phase. → executing */
async retryPhase(phaseId: string): Promise<void>;
// ── Getters ────────────────────────────────────────────────
getState(): OrchestratorState;
getPlan(): OrchestratorPlan | null;
getCurrentPhase(): OrchestratorPhase | null;
getStats(): OrchestratorStats;
getStatus(): OrchestratorPersistState;
// ── Internal: Phase Execution ──────────────────────────────
private async executeCurrentPhase(): Promise<void>;
private async executePhase(phase: OrchestratorPhase): Promise<void>;
private async assignPhaseTasks(phase: OrchestratorPhase): Promise<void>;
private handleTaskCompleted(taskId: string): void;
private handleTaskFailed(taskId: string, error: string): void;
private async onPhaseTasksComplete(phase: OrchestratorPhase): Promise<void>;
// ── Internal: Verification ─────────────────────────────────
private async verifyCurrentPhase(): Promise<void>;
private async handleVerificationResult(phase: OrchestratorPhase, result: VerificationResult): Promise<void>;
// ── Internal: Replanning ───────────────────────────────────
private async replanPhase(phase: OrchestratorPhase, failures: string[]): Promise<void>;
// ── Internal: State Machine ────────────────────────────────
private setState(newState: OrchestratorState): void;
private advanceToNextPhase(): Promise<void>;
private persist(): void;
private restore(): void;
}
```
**Key execution flow in `executePhase()`:**
1. Mark phase as `executing`, emit `phaseStarted`
2. For each task in phase:
- Create a `CreateTaskOptions` from `OrchestratorTask`
- Add to `TaskQueue` with proper dependencies + completion phrase
- Store the TaskQueue task ID in `OrchestratorTask.queueTaskId`
3. Poll task completion (listen to TaskQueue events)
4. When all tasks complete → call `onPhaseTasksComplete()`
5. `onPhaseTasksComplete()` triggers verification
**How tasks get assigned to sessions:**
The OrchestratorLoop does NOT manage session assignment directly. It adds tasks to the existing TaskQueue and starts a mini poll loop that assigns pending tasks to idle sessions — the same pattern as RalphLoop's `assignTasks()`. This reuses existing session management.
**Team agent flow:**
For phases with `teamStrategy.type === 'team'`:
- Start a single session with `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`
- Instead of adding individual tasks to TaskQueue, send ONE comprehensive prompt to the lead
- The prompt instructs the lead to create teammates and delegate
- Monitor via TeamWatcher for team task completion + hook events
- Phase completion is detected via the lead's completion phrase
### Step 5: `src/web/routes/orchestrator-routes.ts` — API endpoints
~300 lines.
```
POST /api/orchestrator/start — { goal, config? } → start planning
POST /api/orchestrator/approve — approve generated plan
POST /api/orchestrator/reject — { feedback } → reject + replan
POST /api/orchestrator/pause — pause execution
POST /api/orchestrator/resume — resume execution
POST /api/orchestrator/stop — stop orchestration
GET /api/orchestrator/status — full state + plan + stats
GET /api/orchestrator/plan — plan details only
POST /api/orchestrator/phase/:id/skip — skip a phase
POST /api/orchestrator/phase/:id/retry — retry a failed phase
```
Port dependency: `SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort`
The route module receives the OrchestratorLoop instance via the InfraPort (added to `createRouteContext()`).
### Step 6: SSE Events — `src/web/sse-events.ts` additions
```typescript
// ─── 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 phases completed successfully. */
export const OrchestratorCompleted = 'orchestrator:completed' as const;
/** Orchestrator error. */
export const OrchestratorError = 'orchestrator:error' as const;
```
11 new events. Add to `SseEvent` namespace object + mirror in `constants.js`.
### Step 7: State persistence — `src/state-store.ts` additions
Add to `AppState`:
```typescript
orchestrator?: OrchestratorPersistState;
```
Add methods:
```typescript
getOrchestratorState(): OrchestratorPersistState | null;
setOrchestratorState(state: Partial<OrchestratorPersistState>): void;
clearOrchestratorState(): void;
```
### Step 8: Server integration — `src/web/server.ts` modifications
1. Import `OrchestratorLoop` and `registerOrchestratorRoutes`
2. Add `private orchestratorLoop: OrchestratorLoop` field
3. Initialize in constructor (lazy — created on first start, not at boot)
4. Add to `createRouteContext()` InfraPort: `orchestratorLoop: this.orchestratorLoop`
5. Wire up OrchestratorLoop events → SSE broadcasts
6. Register routes: `registerOrchestratorRoutes(this.app, ctx)`
7. Clean up in `stop()`
### Step 9: `src/web/public/orchestrator-ui.js` — Frontend panel
~500 lines. New frontend module.
**Load order**: After `panels-ui.js` (11), before `ralph-wizard.js` (13). So load order = 11.5.
**UI elements:**
- Goal input form (text area + config toggles)
- Plan approval view (phase list, task details, approve/reject buttons)
- Execution dashboard (progress bar, phase cards, task status indicators)
- Agent activity panel (session count, team status)
- Controls (pause, resume, stop, skip phase, retry phase)
**SSE listeners:**
- All 11 orchestrator events → update UI state
- Reuses existing session/respawn/team event handlers for agent monitoring
### Step 10: `src/prompts/orchestrator.ts` — Prompt templates
~200 lines.
Templates for:
- Phase execution prompt (tells Claude what to do in this phase)
- Team lead delegation prompt (instructs lead to create and coordinate teammates)
- Verification prompt (asks Claude to verify phase output)
- Replan prompt (gives failure context, asks for recovery steps)
### Step 11: Constants, schemas, route barrel updates
- `src/web/public/constants.js` — Add 11 SSE event mirrors
- `src/web/schemas.ts` — Add Zod schemas for orchestrator API input validation
- `src/web/routes/index.ts` — Export `registerOrchestratorRoutes`
- `src/web/ports/infra-port.ts` — Add `orchestratorLoop` to InfraPort
- `src/types/index.ts` — Export orchestrator types
## Existing File Modifications Summary
| File | Change | Lines |
|------|--------|-------|
| `src/types/index.ts` | Add orchestrator barrel export | +1 |
| `src/web/sse-events.ts` | Add 11 orchestrator events + SseEvent entries | +30 |
| `src/web/public/constants.js` | Mirror 11 SSE events | +15 |
| `src/web/routes/index.ts` | Export registerOrchestratorRoutes | +1 |
| `src/web/ports/infra-port.ts` | Add orchestratorLoop to InfraPort | +3 |
| `src/web/server.ts` | Initialize OrchestratorLoop, wire events, register routes | +40 |
| `src/web/schemas.ts` | Add orchestrator Zod schemas | +20 |
| `src/state-store.ts` | Add orchestrator state persistence | +20 |
| `src/web/public/app.js` | Add orchestrator SSE listeners + panel toggle | +30 |
| `src/web/public/index.html` | Add orchestrator-ui.js script tag | +1 |
**Total new code**: ~2,300 lines across 6 new files
**Total modifications**: ~160 lines across 10 existing files
## Implementation Execution Order
This is the actual build order — each step is a commit checkpoint:
1. **Types** — `src/types/orchestrator.ts` + barrel export. Zero risk, pure types.
2. **SSE events** — Add all 11 events to both `sse-events.ts` and `constants.js`. Wire in SseEvent namespace.
3. **State persistence** — Add orchestrator state to StateStore. Small, isolated change.
4. **Schemas** — Add Zod validation schemas for API input.
5. **Planner** — `src/orchestrator-planner.ts`. Can test in isolation.
6. **Verifier** — `src/orchestrator-verifier.ts`. Can test in isolation.
7. **Core loop** — `src/orchestrator-loop.ts`. The big one. Depends on planner + verifier.
8. **Prompts** — `src/prompts/orchestrator.ts`. Templates used by core loop.
9. **Port + routes** — `src/web/ports/infra-port.ts` update + `src/web/routes/orchestrator-routes.ts`.
10. **Server integration** — Wire OrchestratorLoop into WebServer. Routes become live.
11. **Frontend** — `src/web/public/orchestrator-ui.js` + app.js listeners + index.html script tag.
12. **Tests** — `test/orchestrator-*.test.ts`.
13. **Typecheck + lint** — Fix all issues, ensure CI passes.
## Edge Cases & Error Handling
- **Session limit reached**: Queue tasks and wait for sessions to free up (existing SessionManager handles this)
- **All sessions crash during phase**: Mark phase as failed, attempt replan
- **Verification flaky**: `moderate` mode allows test retries; `lenient` skips AI review
- **Plan too large**: Cap at 10 phases, 50 total tasks. Warn user.
- **Context overflow**: Auto-compact between phases. Respawn if needed (orchestrator state is external).
- **User pauses mid-phase**: Pause task assignment, don't cancel running tasks. Resume picks up where it left off.
- **Network/API errors during planning**: Retry plan generation up to 2 times, then fail with clear message.
- **Orchestrator vs Ralph conflict**: Mutually exclusive. Starting orchestrator stops Ralph if running. Starting Ralph stops orchestrator.
## Testing Strategy
- **Unit tests**: `test/orchestrator-planner.test.ts` — phase grouping algorithm, team strategy assignment
- **Unit tests**: `test/orchestrator-verifier.test.ts` — verification logic with mocked sessions
- **Integration tests**: `test/orchestrator-loop.test.ts` — state machine transitions, task lifecycle
- **Route tests**: `test/routes/orchestrator-routes.test.ts` — API validation, status responses
All tests use `MockSession` pattern from existing test infrastructure. No real tmux needed.
+157
View File
@@ -0,0 +1,157 @@
# Orchestrator Loop — Research Findings
> Research doc for the new "Orchestrator Loop" feature. Not for GitHub.
## What We're Building
A new autonomous loop variant — **Orchestrator Loop** — that takes high-level user tasks, decomposes them into a detailed plan using team agents, and executes the plan step-by-step with quality gates. Unlike Ralph Loop (which executes a flat task queue), the Orchestrator coordinates **planning, delegation, and verification** as a continuous cycle.
**Core idea**: User inputs a goal → Orchestrator creates a detailed plan → spins up team agents for parallel execution → validates each step → adapts the plan based on results → delivers polished output.
## Existing Infrastructure Analysis
### What We Can Reuse
#### 1. Ralph Loop (`src/ralph-loop.ts`)
- **Pattern**: Poll loop with `start() → tick() → stop()` lifecycle
- **Reusable**: Event-driven task assignment, session completion handling, timeout management
- **Limitation**: Flat task queue — no concept of phases, dependencies between task groups, or adaptive replanning
- **Key insight**: `assignTaskToSession()` uses `session.sendInput(task.prompt)` — simple prompt injection into PTY
#### 2. Task Queue (`src/task-queue.ts`) + Task (`src/task.ts`)
- **Already has**: Priority ordering, dependency tracking between tasks, completion phrase detection
- **Limitation**: No task *groups* or *phases*. Dependencies are task-to-task, not phase-to-phase
- **Key insight**: Tasks support `completionPhrase` — a string the task watches for in output. This is how Ralph knows a task is done
#### 3. Plan Orchestrator (`src/plan-orchestrator.ts`)
- **Already has**: 2-agent plan generation (Research Agent → Planner Agent), TDD-aware plan items with P0/P1/P2 priorities
- **Output**: `PlanItem[]` with dependencies, verification criteria, TDD phases, complexity ratings
- **Limitation**: Plan generation only — no execution. Plans are generated then sit in state/UI for human review
- **Key insight**: Uses `Session` directly to run Claude subagent instances for research and planning. Returns structured JSON
#### 4. Team Agents (`src/team-watcher.ts`, `~/.claude/teams/`)
- **Already has**: Team creation, member tracking, filesystem inbox messaging, task management via `~/.claude/tasks/{team-name}/`
- **Limitation**: Codeman can only *observe* teams (TeamWatcher is read-only polling), not *create* or *orchestrate* them
- **Key insight**: Teams are a Claude Code feature. Codeman monitors them but doesn't control them. We can't programmatically create teammates — Claude Code does that when you use `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`
#### 5. Respawn Controller (`src/respawn-controller.ts`)
- **Already has**: Preset-based automation (ralph-todo, overnight-autonomous), circuit breaker, health scoring
- **Key insight**: The `ralph-todo` preset (8s idle, 480min max) is designed for autonomous task execution. We'd need a new preset or make Orchestrator Loop set its own timing
#### 6. Session Auto-Ops (`src/session-auto-ops.ts`)
- **Already has**: Auto-compact at token thresholds, auto-clear for context management
- **Key insight**: Critical for long Orchestrator runs — prevents context overflow during multi-step execution
#### 7. Hooks (`src/hooks-config.ts`)
- **Already has**: `idle_prompt`, `stop`, `teammate_idle`, `task_completed` hook events
- **Key insight**: Hooks fire POST to `/api/hook-event` — this is how Codeman knows when Claude is idle, stopped, or completed a task. The Orchestrator Loop can listen to these same events
### What We Need to Build New
1. **Plan → Task decomposition**: Convert PlanOrchestrator output (PlanItem[]) into executable task groups with phase ordering
2. **Multi-phase execution engine**: Execute plan phases sequentially, tasks within phases in parallel
3. **Verification gates**: After each phase, run verification (test commands, AI review) before proceeding
4. **Adaptive replanning**: When a task fails or verification fails, generate a recovery plan
5. **Team agent orchestration**: Leverage Claude Code's agent teams for parallel execution within phases
6. **Progress tracking & UI**: Real-time dashboard showing plan progress, phase status, agent activity
## How Teams Actually Work (Important Constraint)
After deep research, here's the reality of agent teams:
```
User starts session with CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1
→ Claude Code creates a team-lead
→ Team-lead spawns teammates (in-process threads)
→ Teammates appear as subagents (detected by SubagentWatcher)
→ Communication via ~/.claude/teams/{name}/inboxes/{member}.json
→ Tasks tracked in ~/.claude/tasks/{team-name}/{N}.json
```
**Codeman cannot programmatically create team members.** This is a Claude Code internal feature. However, Codeman CAN:
- Start a session that has teams enabled
- Send a prompt to the lead that instructs it to use agent teams
- Monitor team activity via TeamWatcher
- React to teammate_idle and task_completed hook events
- Read team task status from the filesystem
**This means**: The Orchestrator Loop orchestrates at the *session prompt* level, not the *team member* level. We tell the lead what to do, and the lead decides how to use its team.
## Architecture Decision: Prompt-Level Orchestration
Given the team constraint, the Orchestrator Loop works by:
1. **Planning phase**: Use PlanOrchestrator to generate a detailed plan from user input
2. **Execution phase**: Feed plan steps as prompts to sessions, one phase at a time
3. **Verification phase**: After each phase, run verification prompts and check results
4. **Adaptation phase**: If verification fails, generate recovery prompts
The "team agents" aspect works by:
- Starting sessions with `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`
- Crafting prompts that *instruct the lead to delegate* to teammates
- Monitoring team activity to track parallel progress
- The lead agent is smart enough to decompose work across its team
## Key Technical Findings
### Session Input Mechanics
```typescript
// From session.ts - how we send prompts
await session.sendInput(task.prompt); // Uses writeViaMux() internally
// writeViaMux() does: tmux send-keys -l "prompt text" + tmux send-keys Enter
// CRITICAL: Single-line only! Multi-line breaks Ink rendering
```
### Completion Detection Chain
```
PTY output → RalphTracker.processData() → completion phrase fuzzy match
→ CompletionConfidence scoring (multi-signal: promise tag + todos + exit signal)
→ If confident → emit 'completionDetected'
→ RalphLoop listens → marks task complete → assigns next
```
### How Plan Items Map to Tasks
```typescript
// PlanItem has:
interface PlanItem {
id: string; // "P0-001"
content: string; // "Implement error handling for API endpoints"
priority: 'P0' | 'P1' | 'P2';
dependencies: string[]; // ["P0-000"] — other PlanItem IDs
verificationCriteria: string;
testCommand: string;
tddPhase: 'setup' | 'test' | 'impl' | 'verify' | 'review';
complexity: 'low' | 'medium' | 'high';
}
// Task has:
interface CreateTaskOptions {
prompt: string;
priority: number;
dependencies: string[]; // Task IDs
completionPhrase: string;
timeoutMs: number;
}
// Natural mapping: PlanItem.content → Task.prompt
// PlanItem.dependencies → Task.dependencies
// PlanItem.priority → Task.priority (P0=100, P1=50, P2=10)
// PlanItem.verificationCriteria → verification task prompt
```
### Context Management for Long Runs
- Auto-compact at ~110k tokens (configurable)
- Auto-clear at ~140k tokens (configurable)
- Respawn cycling: kill + restart session to reset context entirely
- For Orchestrator: we want compact between phases, respawn between major milestones
## Risk Assessment
| Risk | Severity | Mitigation |
|------|----------|------------|
| Context overflow during complex phases | High | Auto-compact between tasks, respawn between phases |
| Team agents not predictable | Medium | Orchestrate at session level, let Claude decide team delegation |
| Plan too ambitious → infinite loop | High | Phase budgets (max attempts per phase), circuit breaker |
| Verification too strict → blocks progress | Medium | Configurable strictness, human override via UI |
| Single-line prompt limit | Medium | Use CLAUDE.md file for complex instructions, prompt references file |
| Long planning phase delays execution | Low | Show plan for approval before execution |
+912
View File
@@ -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();
}
}
+412
View File
@@ -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';
}
}
+292
View File
@@ -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
View File
@@ -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';
+116
View File
@@ -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>`;
+24
View File
@@ -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;
+2
View File
@@ -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 ==========
+2
View File
@@ -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';
+285
View File
@@ -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(),
};
}
+1
View File
@@ -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';
+11
View File
@@ -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;
}
+17
View File
@@ -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)
+13
View File
@@ -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',
};
// ═══════════════════════════════════════════════════════════════
+12
View File
@@ -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">&times;</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>
+456
View File
@@ -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());
}
},
});
+219
View File
@@ -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;
}
+1
View File
@@ -14,4 +14,5 @@ export { registerSessionRoutes } from './session-routes.js';
export { registerRespawnRoutes } from './respawn-routes.js';
export { registerRalphRoutes } from './ralph-routes.js';
export { registerPlanRoutes } from './plan-routes.js';
export { registerOrchestratorRoutes } from './orchestrator-routes.js';
export { registerWsRoutes } from './ws-routes.js';
+250
View File
@@ -0,0 +1,250 @@
/**
* @fileoverview Orchestrator loop routes — plan-based autonomous execution.
*
* Endpoints:
* - POST /api/orchestrator/start — Start orchestration with a goal
* - POST /api/orchestrator/approve — Approve generated plan
* - POST /api/orchestrator/reject — Reject plan with feedback
* - POST /api/orchestrator/pause — Pause execution
* - POST /api/orchestrator/resume — Resume from pause
* - POST /api/orchestrator/stop — Stop and clean up
* - GET /api/orchestrator/status — Get current status
* - GET /api/orchestrator/plan — Get current plan
* - POST /api/orchestrator/phase/:id/skip — Skip a phase
* - POST /api/orchestrator/phase/:id/retry — Retry a failed phase
*
* @module web/routes/orchestrator-routes
*/
import { FastifyInstance } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import { OrchestratorStartSchema, OrchestratorRejectSchema } from '../schemas.js';
import { SseEvent } from '../sse-events.js';
import type { EventPort, OrchestratorPort } from '../ports/index.js';
export function registerOrchestratorRoutes(app: FastifyInstance, ctx: OrchestratorPort & EventPort): void {
// ═══════════════════════════════════════════════════════════════
// Helpers
// ═══════════════════════════════════════════════════════════════
function getLoop() {
const loop = ctx.orchestratorLoop;
if (!loop) {
throw Object.assign(new Error('Orchestrator not initialized'), {
statusCode: 503,
body: createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'Orchestrator not initialized'),
});
}
return loop;
}
function setupEventForwarding(loop: import('../../orchestrator-loop.js').OrchestratorLoop) {
loop.on('stateChanged', (state, prevState) => {
ctx.broadcast(SseEvent.OrchestratorStateChanged, { state, prevState });
});
loop.on('planReady', (plan) => {
ctx.broadcast(SseEvent.OrchestratorPlanReady, { plan });
});
loop.on('phaseStarted', (phase) => {
ctx.broadcast(SseEvent.OrchestratorPhaseStarted, { phase });
});
loop.on('phaseCompleted', (phase) => {
ctx.broadcast(SseEvent.OrchestratorPhaseCompleted, { phase });
});
loop.on('phaseFailed', (phase, reason) => {
ctx.broadcast(SseEvent.OrchestratorPhaseFailed, { phase, reason });
});
loop.on('verificationResult', (phase, result) => {
ctx.broadcast(SseEvent.OrchestratorVerification, { phaseId: phase.id, result });
});
loop.on('taskAssigned', (task, sessionId) => {
ctx.broadcast(SseEvent.OrchestratorTaskAssigned, { task, sessionId });
});
loop.on('taskCompleted', (task) => {
ctx.broadcast(SseEvent.OrchestratorTaskCompleted, { task });
});
loop.on('taskFailed', (task, error) => {
ctx.broadcast(SseEvent.OrchestratorTaskFailed, { task, error });
});
loop.on('completed', (stats) => {
ctx.broadcast(SseEvent.OrchestratorCompleted, { stats });
});
loop.on('error', (error) => {
ctx.broadcast(SseEvent.OrchestratorError, { error: error.message });
});
}
// ═══════════════════════════════════════════════════════════════
// Start
// ═══════════════════════════════════════════════════════════════
app.post('/api/orchestrator/start', async (req) => {
const parsed = OrchestratorStartSchema.safeParse(req.body);
if (!parsed.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const { goal, config } = parsed.data;
// Initialize loop if needed
let loop = ctx.orchestratorLoop;
if (!loop) {
loop = ctx.initOrchestratorLoop();
setupEventForwarding(loop);
}
// Check if already running
if (loop.isRunning()) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Orchestrator is already running');
}
try {
// Start is async — kicks off planning
loop.start(goal).catch((err) => {
console.error('[Orchestrator Route] Start failed:', getErrorMessage(err));
});
return {
ok: true,
state: loop.state,
message: 'Orchestrator started — generating plan',
config: config ?? null,
};
} catch (err) {
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, getErrorMessage(err));
}
});
// ═══════════════════════════════════════════════════════════════
// Approve / Reject Plan
// ═══════════════════════════════════════════════════════════════
app.post('/api/orchestrator/approve', async () => {
const loop = getLoop();
try {
loop.approve().catch((err) => {
console.error('[Orchestrator Route] Approve failed:', getErrorMessage(err));
});
return { ok: true, state: loop.state };
} catch (err) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
}
});
app.post('/api/orchestrator/reject', async (req) => {
const loop = getLoop();
const parsed = OrchestratorRejectSchema.safeParse(req.body);
if (!parsed.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Feedback is required');
}
try {
loop.reject(parsed.data.feedback).catch((err) => {
console.error('[Orchestrator Route] Reject failed:', getErrorMessage(err));
});
return { ok: true, state: loop.state };
} catch (err) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
}
});
// ═══════════════════════════════════════════════════════════════
// Pause / Resume / Stop
// ═══════════════════════════════════════════════════════════════
app.post('/api/orchestrator/pause', async () => {
const loop = getLoop();
try {
loop.pause();
return { ok: true, state: loop.state };
} catch (err) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
}
});
app.post('/api/orchestrator/resume', async () => {
const loop = getLoop();
try {
loop.resume().catch((err) => {
console.error('[Orchestrator Route] Resume failed:', getErrorMessage(err));
});
return { ok: true, state: loop.state };
} catch (err) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
}
});
app.post('/api/orchestrator/stop', async () => {
const loop = getLoop();
try {
await loop.stop();
return { ok: true, state: loop.state };
} catch (err) {
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, getErrorMessage(err));
}
});
// ═══════════════════════════════════════════════════════════════
// Status / Plan
// ═══════════════════════════════════════════════════════════════
app.get('/api/orchestrator/status', async () => {
const loop = ctx.orchestratorLoop;
if (!loop) {
return { ok: true, state: 'idle', plan: null, stats: null };
}
return {
ok: true,
...loop.getStatus(),
};
});
app.get('/api/orchestrator/plan', async () => {
const loop = ctx.orchestratorLoop;
if (!loop) {
return { ok: true, plan: null };
}
return {
ok: true,
plan: loop.getPlan(),
currentPhase: loop.getCurrentPhase(),
};
});
// ═══════════════════════════════════════════════════════════════
// Phase Operations
// ═══════════════════════════════════════════════════════════════
app.post('/api/orchestrator/phase/:id/skip', async (req) => {
const loop = getLoop();
const { id } = req.params as { id: string };
try {
await loop.skipPhase(id);
return { ok: true, state: loop.state };
} catch (err) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
}
});
app.post('/api/orchestrator/phase/:id/retry', async (req) => {
const loop = getLoop();
const { id } = req.params as { id: string };
try {
loop.retryPhase(id).catch((err) => {
console.error('[Orchestrator Route] Retry failed:', getErrorMessage(err));
});
return { ok: true, state: loop.state };
} catch (err) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
}
});
}
+28
View File
@@ -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>;
+21
View File
@@ -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
View File
@@ -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,
+4
View File
@@ -113,6 +113,10 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
qrAuthFailures: null,
// https already declared above in ConfigPort (shared property)
// -- OrchestratorPort --
orchestratorLoop: null,
initOrchestratorLoop: vi.fn(),
// Convenience accessors (not part of any port interface)
_session: session,
_sessionId: sessionId,
+102
View File
@@ -0,0 +1,102 @@
/**
* @fileoverview Tests for OrchestratorPlanner phase grouping logic.
* Tests the groupIntoPhases algorithm: topological sort, TDD grouping, merging.
*
* Port: N/A (no HTTP)
*/
import { describe, it, expect, vi } from 'vitest';
// We test the private groupIntoPhases via the public generatePlan interface,
// or by accessing internals. For unit testing the algorithm directly, we use
// a subclass that exposes the private method.
//
// Since groupIntoPhases is private and the class requires a real mux,
// we test the planner types and verification logic instead.
import { OrchestratorVerifier } from '../src/orchestrator-verifier.js';
import {
DEFAULT_ORCHESTRATOR_CONFIG,
createInitialOrchestratorStats,
type OrchestratorConfig,
type OrchestratorPhase,
type OrchestratorTask,
} from '../src/types.js';
describe('OrchestratorVerifier', () => {
function createTestPhase(overrides?: Partial<OrchestratorPhase>): OrchestratorPhase {
return {
id: 'phase-1',
name: 'Test Phase',
description: 'Test',
order: 0,
status: 'executing',
tasks: [],
verificationCriteria: [],
testCommands: [],
maxAttempts: 3,
attempts: 1,
startedAt: Date.now(),
completedAt: null,
durationMs: null,
teamStrategy: { type: 'single' },
...overrides,
};
}
describe('evaluateChecks', () => {
it('passes with no checks', async () => {
const verifier = new OrchestratorVerifier(DEFAULT_ORCHESTRATOR_CONFIG);
const phase = createTestPhase();
// No test commands, no criteria → should pass
const mockSession = {
on: vi.fn(),
off: vi.fn(),
sendInput: vi.fn(),
};
const result = await verifier.verifyPhase(phase, mockSession as never);
expect(result.passed).toBe(true);
expect(result.checks).toHaveLength(0);
});
it('skips verification in lenient mode with no checks', async () => {
const config: OrchestratorConfig = { ...DEFAULT_ORCHESTRATOR_CONFIG, verificationMode: 'lenient' };
const verifier = new OrchestratorVerifier(config);
const phase = createTestPhase();
const mockSession = {
on: vi.fn(),
off: vi.fn(),
sendInput: vi.fn(),
};
const result = await verifier.verifyPhase(phase, mockSession as never);
expect(result.passed).toBe(true);
expect(result.summary).toContain('skipped');
});
});
});
describe('OrchestratorConfig defaults', () => {
it('has sensible defaults', () => {
expect(DEFAULT_ORCHESTRATOR_CONFIG.plannerModel).toBe('opus');
expect(DEFAULT_ORCHESTRATOR_CONFIG.verificationMode).toBe('moderate');
expect(DEFAULT_ORCHESTRATOR_CONFIG.maxPhaseRetries).toBe(3);
expect(DEFAULT_ORCHESTRATOR_CONFIG.enableTeamAgents).toBe(true);
expect(DEFAULT_ORCHESTRATOR_CONFIG.autoApprove).toBe(false);
});
});
describe('createInitialOrchestratorStats', () => {
it('creates zeroed stats', () => {
const stats = createInitialOrchestratorStats();
expect(stats.phasesCompleted).toBe(0);
expect(stats.phasesFailed).toBe(0);
expect(stats.totalTasksCompleted).toBe(0);
expect(stats.totalTasksFailed).toBe(0);
expect(stats.replanCount).toBe(0);
expect(stats.totalDurationMs).toBe(0);
});
});
+335
View File
@@ -0,0 +1,335 @@
/**
* @fileoverview Tests for orchestrator-routes route handlers.
*
* Uses app.inject() — no real HTTP ports needed.
* Port: N/A (app.inject doesn't open ports)
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerOrchestratorRoutes } from '../../src/web/routes/orchestrator-routes.js';
describe('orchestrator-routes', () => {
let harness: RouteTestHarness;
beforeEach(async () => {
harness = await createRouteTestHarness(registerOrchestratorRoutes);
});
afterEach(async () => {
await harness.app.close();
});
// ========== GET /api/orchestrator/status ==========
describe('GET /api/orchestrator/status', () => {
it('returns idle when orchestrator is not initialized', async () => {
const res = await harness.app.inject({
method: 'GET',
url: '/api/orchestrator/status',
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.ok).toBe(true);
expect(body.state).toBe('idle');
expect(body.plan).toBeNull();
});
it('returns current status when loop exists', async () => {
const mockLoop = {
getStatus: vi.fn(() => ({
state: 'executing',
plan: { id: 'plan-1', goal: 'test', phases: [] },
currentPhaseIndex: 0,
startedAt: Date.now(),
completedAt: null,
config: {},
stats: {},
})),
};
(harness.ctx as Record<string, unknown>).orchestratorLoop = mockLoop;
const res = await harness.app.inject({
method: 'GET',
url: '/api/orchestrator/status',
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.ok).toBe(true);
expect(body.state).toBe('executing');
});
});
// ========== GET /api/orchestrator/plan ==========
describe('GET /api/orchestrator/plan', () => {
it('returns null plan when not initialized', async () => {
const res = await harness.app.inject({
method: 'GET',
url: '/api/orchestrator/plan',
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.ok).toBe(true);
expect(body.plan).toBeNull();
});
it('returns plan and current phase when loop exists', async () => {
const mockPlan = { id: 'plan-1', goal: 'test', phases: [{ id: 'p1', name: 'Phase 1' }] };
const mockLoop = {
getPlan: vi.fn(() => mockPlan),
getCurrentPhase: vi.fn(() => mockPlan.phases[0]),
};
(harness.ctx as Record<string, unknown>).orchestratorLoop = mockLoop;
const res = await harness.app.inject({
method: 'GET',
url: '/api/orchestrator/plan',
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.ok).toBe(true);
expect(body.plan.id).toBe('plan-1');
expect(body.currentPhase.id).toBe('p1');
});
});
// ========== POST /api/orchestrator/start ==========
describe('POST /api/orchestrator/start', () => {
it('rejects empty goal', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/orchestrator/start',
payload: { goal: '' },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
it('rejects missing goal', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/orchestrator/start',
payload: {},
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
it('initializes loop and starts when valid', async () => {
const mockLoop = {
isRunning: vi.fn(() => false),
start: vi.fn(async () => {}),
state: 'planning',
on: vi.fn(),
};
harness.ctx.initOrchestratorLoop = vi.fn(() => mockLoop as never);
const res = await harness.app.inject({
method: 'POST',
url: '/api/orchestrator/start',
payload: { goal: 'Build a REST API' },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.ok).toBe(true);
expect(harness.ctx.initOrchestratorLoop).toHaveBeenCalled();
});
it('rejects start when already running', async () => {
const mockLoop = {
isRunning: vi.fn(() => true),
state: 'executing',
on: vi.fn(),
};
(harness.ctx as Record<string, unknown>).orchestratorLoop = mockLoop;
const res = await harness.app.inject({
method: 'POST',
url: '/api/orchestrator/start',
payload: { goal: 'Another goal' },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
});
// ========== POST /api/orchestrator/stop ==========
describe('POST /api/orchestrator/stop', () => {
it('returns error when loop not initialized', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/orchestrator/stop',
});
// Should return 503 via thrown error
expect(res.statusCode).toBe(503);
});
it('stops the loop', async () => {
const mockLoop = {
stop: vi.fn(async () => {}),
state: 'idle',
};
(harness.ctx as Record<string, unknown>).orchestratorLoop = mockLoop;
const res = await harness.app.inject({
method: 'POST',
url: '/api/orchestrator/stop',
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.ok).toBe(true);
expect(mockLoop.stop).toHaveBeenCalled();
});
});
// ========== POST /api/orchestrator/approve ==========
describe('POST /api/orchestrator/approve', () => {
it('returns error when loop not initialized', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/orchestrator/approve',
});
expect(res.statusCode).toBe(503);
});
it('calls approve on the loop', async () => {
const mockLoop = {
approve: vi.fn(async () => {}),
state: 'approval',
};
(harness.ctx as Record<string, unknown>).orchestratorLoop = mockLoop;
const res = await harness.app.inject({
method: 'POST',
url: '/api/orchestrator/approve',
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.ok).toBe(true);
});
});
// ========== POST /api/orchestrator/reject ==========
describe('POST /api/orchestrator/reject', () => {
it('rejects missing feedback', async () => {
const mockLoop = { state: 'approval' };
(harness.ctx as Record<string, unknown>).orchestratorLoop = mockLoop;
const res = await harness.app.inject({
method: 'POST',
url: '/api/orchestrator/reject',
payload: {},
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
it('calls reject with feedback', async () => {
const mockLoop = {
reject: vi.fn(async () => {}),
state: 'approval',
};
(harness.ctx as Record<string, unknown>).orchestratorLoop = mockLoop;
const res = await harness.app.inject({
method: 'POST',
url: '/api/orchestrator/reject',
payload: { feedback: 'Add more tests' },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.ok).toBe(true);
});
});
// ========== POST /api/orchestrator/pause ==========
describe('POST /api/orchestrator/pause', () => {
it('calls pause on the loop', async () => {
const mockLoop = {
pause: vi.fn(),
state: 'paused',
};
(harness.ctx as Record<string, unknown>).orchestratorLoop = mockLoop;
const res = await harness.app.inject({
method: 'POST',
url: '/api/orchestrator/pause',
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.ok).toBe(true);
});
});
// ========== POST /api/orchestrator/resume ==========
describe('POST /api/orchestrator/resume', () => {
it('calls resume on the loop', async () => {
const mockLoop = {
resume: vi.fn(async () => {}),
state: 'executing',
};
(harness.ctx as Record<string, unknown>).orchestratorLoop = mockLoop;
const res = await harness.app.inject({
method: 'POST',
url: '/api/orchestrator/resume',
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.ok).toBe(true);
});
});
// ========== POST /api/orchestrator/phase/:id/skip ==========
describe('POST /api/orchestrator/phase/:id/skip', () => {
it('calls skipPhase with the phase id', async () => {
const mockLoop = {
skipPhase: vi.fn(async () => {}),
state: 'executing',
};
(harness.ctx as Record<string, unknown>).orchestratorLoop = mockLoop;
const res = await harness.app.inject({
method: 'POST',
url: '/api/orchestrator/phase/phase-1/skip',
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.ok).toBe(true);
expect(mockLoop.skipPhase).toHaveBeenCalledWith('phase-1');
});
});
// ========== POST /api/orchestrator/phase/:id/retry ==========
describe('POST /api/orchestrator/phase/:id/retry', () => {
it('calls retryPhase with the phase id', async () => {
const mockLoop = {
retryPhase: vi.fn(async () => {}),
state: 'failed',
};
(harness.ctx as Record<string, unknown>).orchestratorLoop = mockLoop;
const res = await harness.app.inject({
method: 'POST',
url: '/api/orchestrator/phase/phase-2/retry',
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.ok).toBe(true);
expect(mockLoop.retryPhase).toHaveBeenCalledWith('phase-2');
});
});
});