/** * Scheduler — Main scheduling engine * * Implements contracts §9; DD §7.1 + state machine §20.2. * INV-1: status only via emitted events for projection * INV-5: rebuild queues from SQLite, not EventBus replay * * @module packages/runtime/src/scheduler/Scheduler */ import type { TaskID, SessionID, ProjectID } from '@aircoding/contracts' import { TaskGraph } from './TaskGraph.js' import { WavePlanner } from './WavePlanner.js' import { RetryPlanner } from './RetryPlanner.js' import { WorkspaceManager } from './WorkspaceManager.js' import { AgentMonitor } from './AgentMonitor.js' import { eventIngestor } from '../events/EventIngestor.js' import type { WorkerManager } from '../workers/WorkerManager.js' export type SchedulerState = | 'IDLE' | 'LOADING_GRAPH' | 'PLANNING_WAVE' | 'DISPATCHING' | 'MONITORING' | 'COLLECTING_RESULTS' | 'MERGING' | 'REVIEWING_WAVE' | 'REPAIRING_OR_CONTINUING' | 'COMPLETED' | 'TERMINATED' | 'BLOCKED' | 'CANCELLED' export interface SchedulerContext { session_id: SessionID project_id: ProjectID project_root: string } export class Scheduler { private state: SchedulerState = 'IDLE' private graph: TaskGraph private wave_planner: WavePlanner private retry_planner: RetryPlanner private workspace_manager: WorkspaceManager private agent_monitor: AgentMonitor private context: SchedulerContext private worker_manager?: WorkerManager constructor(context: SchedulerContext, worker_manager?: WorkerManager) { this.context = context this.graph = new TaskGraph() this.wave_planner = new WavePlanner() this.retry_planner = new RetryPlanner() this.workspace_manager = new WorkspaceManager(context.project_root) this.agent_monitor = new AgentMonitor() this.worker_manager = worker_manager } /** * Create tasks from specifications. */ create_tasks(tasks: Array<{ id: TaskID; type: string; title: string; depends_on?: string[] }>): void { for (const task of tasks) { this.graph.add_task({ id: task.id, status: 'pending', dependencies: task.depends_on?.map(d => ({ task_id: d, type: 'hard' as const })) || [] }) } // Emit task.created events (INV-1: via projection, not direct status write) this.state = 'PLANNING_WAVE' } /** * Run until idle — drives state machine to terminal state. */ async run_until_idle(): Promise { while ( this.state !== 'COMPLETED' && this.state !== 'TERMINATED' && this.state !== 'BLOCKED' && this.state !== 'CANCELLED' ) { await this.step() } return this.state } /** * Execute one scheduler step. */ async step(): Promise { switch (this.state) { case 'IDLE': this.state = 'LOADING_GRAPH' break case 'LOADING_GRAPH': // Validate graph references const validation = this.graph.validate_refs() if (!validation.valid) { console.error('Graph validation failed:', validation.errors) this.state = 'TERMINATED' return } this.state = 'PLANNING_WAVE' break case 'PLANNING_WAVE': { // Check if all tasks done const counts = this.graph.count_by_status() const remaining = (counts.pending || 0) + (counts.running || 0) if (remaining === 0) { this.state = 'COMPLETED' return } // Plan next wave const plan = this.wave_planner.plan(this.graph) if (plan.length === 0) { // Check for blocked tasks const pending = this.graph.count_by_status().pending || 0 if (pending > 0) { this.state = 'REPAIRING_OR_CONTINUING' return } this.state = 'COMPLETED' return } this.state = 'DISPATCHING' break } case 'DISPATCHING': { const runnable = this.graph.get_runnable_tasks() for (const task of runnable) { const agent_id = `agent_${task.id}` // INV-1: Emit task.started event (durable) for projection const now = new Date().toISOString() await eventIngestor.ingest({ id: `evt_${task.id}_started`, type: 'task.started', version: 1, session_id: this.context.session_id, project_id: this.context.project_id, timestamp: now, source: { kind: 'scheduler' }, route: ['scheduler', 'dispatch'], payload: { task_id: task.id, agent_id, attempt_id: `${task.id}_1`, attempt_index: 0, workspace_id: `ws_${task.id}` } }) if (this.worker_manager) { try { await this.worker_manager.spawn({ entrypoint: 'packages/workers/src/main.ts', agent_id, session_id: this.context.session_id, project_root: this.context.project_root, }) this.agent_monitor.record_heartbeat(agent_id, task.id) } catch { // INV-1: emit task.failed event for projection await eventIngestor.ingest({ id: `evt_${task.id}_failed`, type: 'task.failed', version: 1, session_id: this.context.session_id, project_id: this.context.project_id, timestamp: new Date().toISOString(), source: { kind: 'scheduler' }, route: ['scheduler', 'dispatch'], payload: { task_id: task.id, agent_id, attempt_id: `${task.id}_1`, error: { message: 'Worker spawn failed' }, evidence_refs: [], metadata: {} } }) } } else { this.agent_monitor.record_heartbeat(agent_id, task.id) } } this.state = 'MONITORING' break } case 'MONITORING': // Check agent health const lost = this.agent_monitor.detect_lost_agents() for (const l of lost) { // Emit agent.lost + task.failed events for projection (INV-1/INV-5) const hb = this.agent_monitor.get(l.agent_id) if (hb) { const now = new Date().toISOString() await eventIngestor.ingest({ id: `evt_${hb.task_id}_lost`, type: 'agent.lost', version: 1, session_id: this.context.session_id, project_id: this.context.project_id, timestamp: now, source: { kind: 'scheduler' }, route: ['scheduler', 'monitoring'], payload: { agent_id: l.agent_id, task_id: hb.task_id, last_heartbeat_at: l.last_heartbeat, detection_reason: l.state } }) await eventIngestor.ingest({ id: `evt_${hb.task_id}_failed`, type: 'task.failed', version: 1, session_id: this.context.session_id, project_id: this.context.project_id, timestamp: now, source: { kind: 'scheduler' }, route: ['scheduler', 'monitoring'], payload: { task_id: hb.task_id, agent_id: l.agent_id, attempt_id: '', error: { message: `Agent ${l.state}` }, evidence_refs: [], metadata: {} } }) this.agent_monitor.remove(l.agent_id) } } const timeouts = this.agent_monitor.enforce_timeouts() for (const t of timeouts) { const hb = this.agent_monitor.get(t.agent_id) const task_id = hb?.task_id switch (t.action) { case 'hard_cancel': case 'soft_cancel': if (task_id) { await eventIngestor.ingest({ id: `evt_${task_id}_cancelled`, type: 'agent.cancelled', version: 1, session_id: this.context.session_id, project_id: this.context.project_id, timestamp: new Date().toISOString(), source: { kind: 'scheduler' }, route: ['scheduler', 'monitoring'], payload: { agent_id: t.agent_id, task_id, reason: t.action } }) } this.agent_monitor.remove(t.agent_id) break case 'ping': break } } // Check if any running tasks remain const running = (this.graph.count_by_status().running || 0) if (running === 0) { this.state = 'COLLECTING_RESULTS' } break case 'COLLECTING_RESULTS': // Results arrive via events, projection updates task status this.state = 'MERGING' break case 'MERGING': { const active_ws = this.workspace_manager.get_active() for (const ws of active_ws) { await this.workspace_manager.merge_workspace(ws.id) } this.state = 'REVIEWING_WAVE' break } case 'REVIEWING_WAVE': this.state = 'REPAIRING_OR_CONTINUING' break case 'REPAIRING_OR_CONTINUING': { const counts = this.graph.count_by_status() const failed = counts.failed || 0 if (failed > 0) { // Retry logic handled by RetryPlanner } this.state = 'PLANNING_WAVE' break } case 'BLOCKED': case 'CANCELLED': case 'COMPLETED': case 'TERMINATED': break } } /** * Rebuild scheduler state from SQLite (INV-5: from EventStore, not EventBus). */ async rebuild_from_db(): Promise { this.state = 'LOADING_GRAPH' // Would load all tasks from SQLite, reconstruct graph // Load pending/running tasks, agent status, workspaces } /** * Get current state. */ get_state(): SchedulerState { return this.state } /** * Get the task graph (for inspection). */ get_graph(): TaskGraph { return this.graph } }