Files
Codeman/src/group-scheduler.ts
T
arkonandClaude Opus 4.5 55e9f9f0a5 feat(execution): add ExecutionBridge for parallel task execution
Implements the Execution Optimizer Rework plan that enables the system
to actually use the optimizer metadata (parallelGroups, agentType,
recommendedModel, requiresFreshContext, estimatedTokens) that was
previously generated but ignored.

New components:
- ExecutionBridge: Central coordinator that loads optimized plans,
  manages parallel execution within groups, and coordinates with
  SpawnOrchestrator for session-based execution
- ModelSelector: Routes tasks to appropriate models (opus/sonnet/haiku)
  based on user defaults and agent type overrides. Optimizer
  recommendations are advisory only - user preferences always win
- GroupScheduler: Builds topologically ordered execution groups,
  manages dependencies, determines execution mode (session vs task-tool)
- ContextManager: Handles fresh context requirements via /clear+/init
  or new session spawning

Features:
- Parallel task execution within groups (configurable limit)
- Group-level dependency tracking (lower groups complete first)
- Partial failure handling (continue with non-dependent tasks)
- Model configuration in App Settings > Models tab
- Agent type overrides (explore, implement, test, review)
- Execution control API endpoints (start, pause, resume, cancel)
- SSE events for real-time execution progress visibility
- Execution history tracking

API endpoints:
- GET/POST/PUT /api/execution/* for execution control
- GET/PUT /api/execution/model-config for model settings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 07:07:18 +01:00

552 lines
16 KiB
TypeScript

/**
* @fileoverview Group Scheduler - Topological ordering and dependency management.
*
* Builds execution schedules from parallel groups, manages dependencies
* between groups, and tracks group-level completion status.
*
* @module group-scheduler
*/
import { EventEmitter } from 'node:events';
import type { ExecutionMode } from './model-selector.js';
import type { ModelTier, AgentType } from './model-selector.js';
// ========== Types ==========
/** Status of a task within a group */
export type GroupTaskStatus = 'pending' | 'running' | 'completed' | 'failed' | 'blocked' | 'skipped';
/** Status of an execution group */
export type ExecutionGroupStatus = 'pending' | 'ready' | 'running' | 'completed' | 'partial' | 'failed';
/**
* A task within an execution group.
*/
export interface GroupTask {
/** Task ID from the plan */
id: string;
/** Task title/description */
title: string;
/** Full task description */
description: string;
/** Parallel group number */
parallelGroup: number;
/** Agent type for model selection */
agentType: AgentType;
/** Recommended model */
recommendedModel?: ModelTier;
/** Whether task requires fresh context */
requiresFreshContext: boolean;
/** Estimated token usage */
estimatedTokens?: number;
/** Input files (read-only) */
inputFiles?: string[];
/** Output files (will be modified) */
outputFiles?: string[];
/** Current status */
status: GroupTaskStatus;
/** Task dependencies (other task IDs) */
dependencies: string[];
/** Error message if failed */
error?: string;
/** Retry count */
retryCount: number;
}
/**
* An execution group containing tasks that can run in parallel.
*/
export interface ExecutionGroup {
/** Group number (from parallelGroup) */
groupNumber: number;
/** Tasks in this group */
tasks: GroupTask[];
/** Group status */
status: ExecutionGroupStatus;
/** Execution mode for this group */
executionMode: ExecutionMode;
/** Rationale for execution mode choice */
executionModeRationale: string;
/** Groups that must complete before this one */
dependsOnGroups: number[];
/** When group started executing */
startedAt?: number;
/** When group completed */
completedAt?: number;
/** Number of tasks completed */
completedCount: number;
/** Number of tasks failed */
failedCount: number;
/** Number of tasks skipped due to dependency failures */
skippedCount: number;
}
/**
* Full execution schedule.
*/
export interface ExecutionSchedule {
/** Ordered groups (by dependency order) */
groups: ExecutionGroup[];
/** Total task count */
totalTasks: number;
/** Completed task count */
completedTasks: number;
/** Failed task count */
failedTasks: number;
/** Current executing group index (-1 if not started) */
currentGroupIndex: number;
/** Overall status */
status: 'pending' | 'running' | 'completed' | 'partial' | 'failed';
}
// ========== Events ==========
export interface GroupSchedulerEvents {
/** Schedule built from plan */
scheduleBuilt: (schedule: ExecutionSchedule) => void;
/** Group started executing */
groupStarted: (group: ExecutionGroup) => void;
/** Group completed (fully or partially) */
groupCompleted: (group: ExecutionGroup) => void;
/** Task status changed */
taskStatusChanged: (data: { taskId: string; groupNumber: number; oldStatus: GroupTaskStatus; newStatus: GroupTaskStatus }) => void;
}
// ========== Group Scheduler ==========
/**
* GroupScheduler - Manages execution order and dependencies.
*
* Responsibilities:
* - Build topologically ordered groups from plan items
* - Track group dependencies (lower groups must complete first)
* - Handle partial failures (continue with independent tasks)
* - Determine group execution mode (session vs task-tool)
*/
export class GroupScheduler extends EventEmitter {
private _schedule: ExecutionSchedule | null = null;
private _taskToGroup: Map<string, number> = new Map();
constructor() {
super();
}
/**
* Get current schedule.
*/
get schedule(): ExecutionSchedule | null {
return this._schedule;
}
/**
* Build execution schedule from plan items.
*
* @param items - Array of plan items with parallelGroup assignments
* @returns Built execution schedule
*/
buildSchedule(items: Array<{
id: string;
title: string;
description: string;
parallelGroup?: number;
agentType?: string;
recommendedModel?: string;
requiresFreshContext?: boolean;
estimatedTokens?: number;
inputFiles?: string[];
outputFiles?: string[];
dependencies?: string[];
}>): ExecutionSchedule {
// Group items by parallel group
const groupMap = new Map<number, GroupTask[]>();
for (const item of items) {
const groupNum = item.parallelGroup ?? 0;
const task: GroupTask = {
id: item.id,
title: item.title,
description: item.description,
parallelGroup: groupNum,
agentType: (item.agentType as AgentType) ?? 'general',
recommendedModel: item.recommendedModel as ModelTier | undefined,
requiresFreshContext: item.requiresFreshContext ?? false,
estimatedTokens: item.estimatedTokens,
inputFiles: item.inputFiles,
outputFiles: item.outputFiles,
status: 'pending',
dependencies: item.dependencies ?? [],
retryCount: 0,
};
if (!groupMap.has(groupNum)) {
groupMap.set(groupNum, []);
}
groupMap.get(groupNum)!.push(task);
this._taskToGroup.set(item.id, groupNum);
}
// Sort groups by number
const sortedGroupNums = Array.from(groupMap.keys()).sort((a, b) => a - b);
// Build execution groups
const groups: ExecutionGroup[] = sortedGroupNums.map(groupNum => {
const tasks = groupMap.get(groupNum)!;
// Determine which groups this one depends on
const dependsOnGroups = new Set<number>();
for (const task of tasks) {
for (const depId of task.dependencies) {
const depGroup = this._taskToGroup.get(depId);
if (depGroup !== undefined && depGroup !== groupNum && depGroup < groupNum) {
dependsOnGroups.add(depGroup);
}
}
}
// Determine execution mode based on task characteristics
const { mode, rationale } = this.determineGroupExecutionMode(tasks);
return {
groupNumber: groupNum,
tasks,
status: 'pending',
executionMode: mode,
executionModeRationale: rationale,
dependsOnGroups: Array.from(dependsOnGroups).sort((a, b) => a - b),
completedCount: 0,
failedCount: 0,
skippedCount: 0,
};
});
this._schedule = {
groups,
totalTasks: items.length,
completedTasks: 0,
failedTasks: 0,
currentGroupIndex: -1,
status: 'pending',
};
this.emit('scheduleBuilt', this._schedule);
return this._schedule;
}
/**
* Determine execution mode for a group based on task characteristics.
*/
private determineGroupExecutionMode(tasks: GroupTask[]): { mode: ExecutionMode; rationale: string } {
// High token estimate → session mode
const highTokenTask = tasks.find(t => t.estimatedTokens && t.estimatedTokens > 50000);
if (highTokenTask) {
return {
mode: 'session',
rationale: `Task ${highTokenTask.id} has high token estimate (${highTokenTask.estimatedTokens})`,
};
}
// Complex agent types → session mode
const complexTask = tasks.find(t => t.agentType === 'implement' || t.agentType === 'review');
if (complexTask) {
return {
mode: 'session',
rationale: `Task ${complexTask.id} has complex agent type (${complexTask.agentType})`,
};
}
// Multiple output files in any task → session mode
const multiOutputTask = tasks.find(t => t.outputFiles && t.outputFiles.length > 2);
if (multiOutputTask) {
return {
mode: 'session',
rationale: `Task ${multiOutputTask.id} has multiple output files (${multiOutputTask.outputFiles!.length})`,
};
}
// Fresh context required → session mode
const freshContextTask = tasks.find(t => t.requiresFreshContext);
if (freshContextTask) {
return {
mode: 'session',
rationale: `Task ${freshContextTask.id} requires fresh context`,
};
}
// All low-token explore tasks → task-tool mode
const allLowToken = tasks.every(t => !t.estimatedTokens || t.estimatedTokens < 15000);
const allExplore = tasks.every(t => t.agentType === 'explore' || t.agentType === 'general');
if (allLowToken && allExplore) {
return {
mode: 'task-tool',
rationale: 'All tasks are low-token explore/general tasks',
};
}
// Default to session mode for reliability
return {
mode: 'session',
rationale: 'Default to session mode for reliability',
};
}
/**
* Get the next group ready for execution.
*/
getNextReadyGroup(): ExecutionGroup | null {
if (!this._schedule) return null;
for (const group of this._schedule.groups) {
if (group.status === 'pending' && this.areGroupDependenciesSatisfied(group)) {
group.status = 'ready';
return group;
}
}
return null;
}
/**
* Check if a group's dependencies are satisfied.
*/
areGroupDependenciesSatisfied(group: ExecutionGroup): boolean {
if (!this._schedule) return false;
for (const depGroupNum of group.dependsOnGroups) {
const depGroup = this._schedule.groups.find(g => g.groupNumber === depGroupNum);
if (!depGroup) continue;
// Dependency must be completed (fully or partially)
if (depGroup.status !== 'completed' && depGroup.status !== 'partial') {
return false;
}
}
return true;
}
/**
* Mark a group as started.
*/
startGroup(groupNumber: number): void {
if (!this._schedule) return;
const group = this._schedule.groups.find(g => g.groupNumber === groupNumber);
if (!group) return;
group.status = 'running';
group.startedAt = Date.now();
this._schedule.status = 'running';
this._schedule.currentGroupIndex = this._schedule.groups.indexOf(group);
this.emit('groupStarted', group);
}
/**
* Update task status within a group.
*/
updateTaskStatus(taskId: string, status: GroupTaskStatus, error?: string): void {
if (!this._schedule) return;
const groupNum = this._taskToGroup.get(taskId);
if (groupNum === undefined) return;
const group = this._schedule.groups.find(g => g.groupNumber === groupNum);
if (!group) return;
const task = group.tasks.find(t => t.id === taskId);
if (!task) return;
const oldStatus = task.status;
task.status = status;
if (error) task.error = error;
// Update group counters
if (status === 'completed') {
group.completedCount++;
this._schedule.completedTasks++;
} else if (status === 'failed') {
group.failedCount++;
this._schedule.failedTasks++;
} else if (status === 'skipped') {
group.skippedCount++;
}
this.emit('taskStatusChanged', { taskId, groupNumber: groupNum, oldStatus, newStatus: status });
// Check if group is complete
this.checkGroupCompletion(group);
}
/**
* Mark tasks blocked by a failed dependency.
*/
markDependentTasksBlocked(failedTaskId: string): void {
if (!this._schedule) return;
for (const group of this._schedule.groups) {
for (const task of group.tasks) {
if (task.dependencies.includes(failedTaskId) && task.status === 'pending') {
this.updateTaskStatus(task.id, 'skipped', `Blocked by failed task ${failedTaskId}`);
}
}
}
}
/**
* Get tasks ready to execute in a group.
*/
getReadyTasksInGroup(groupNumber: number): GroupTask[] {
if (!this._schedule) return [];
const group = this._schedule.groups.find(g => g.groupNumber === groupNumber);
if (!group) return [];
return group.tasks.filter(task => {
if (task.status !== 'pending') return false;
// Check if task dependencies are satisfied (within and across groups)
for (const depId of task.dependencies) {
// Check if dependency is in the same group
const sameGroupDep = group.tasks.find(t => t.id === depId);
if (sameGroupDep && sameGroupDep.status !== 'completed') {
return false;
}
// Check if dependency is in a different group
const depGroupNum = this._taskToGroup.get(depId);
if (depGroupNum !== undefined && depGroupNum !== groupNumber) {
const depGroup = this._schedule!.groups.find(g => g.groupNumber === depGroupNum);
const depTask = depGroup?.tasks.find(t => t.id === depId);
if (depTask && depTask.status !== 'completed') {
return false;
}
}
}
return true;
});
}
/**
* Check if a group has completed (all tasks done, failed, or skipped).
*/
private checkGroupCompletion(group: ExecutionGroup): void {
const pendingOrRunning = group.tasks.filter(
t => t.status === 'pending' || t.status === 'running'
);
if (pendingOrRunning.length > 0) return;
group.completedAt = Date.now();
// Determine final status
if (group.failedCount === 0 && group.skippedCount === 0) {
group.status = 'completed';
} else if (group.completedCount > 0) {
group.status = 'partial';
} else {
group.status = 'failed';
}
this.emit('groupCompleted', group);
// Check if all groups are done
this.checkScheduleCompletion();
}
/**
* Check if entire schedule has completed.
*/
private checkScheduleCompletion(): void {
if (!this._schedule) return;
const pendingOrRunning = this._schedule.groups.filter(
g => g.status === 'pending' || g.status === 'ready' || g.status === 'running'
);
if (pendingOrRunning.length > 0) return;
// Determine final status
const failedGroups = this._schedule.groups.filter(g => g.status === 'failed');
const partialGroups = this._schedule.groups.filter(g => g.status === 'partial');
if (failedGroups.length === this._schedule.groups.length) {
this._schedule.status = 'failed';
} else if (failedGroups.length > 0 || partialGroups.length > 0) {
this._schedule.status = 'partial';
} else {
this._schedule.status = 'completed';
}
}
/**
* Get schedule statistics.
*/
getStats(): {
totalGroups: number;
completedGroups: number;
failedGroups: number;
partialGroups: number;
totalTasks: number;
completedTasks: number;
failedTasks: number;
skippedTasks: number;
} {
if (!this._schedule) {
return {
totalGroups: 0,
completedGroups: 0,
failedGroups: 0,
partialGroups: 0,
totalTasks: 0,
completedTasks: 0,
failedTasks: 0,
skippedTasks: 0,
};
}
return {
totalGroups: this._schedule.groups.length,
completedGroups: this._schedule.groups.filter(g => g.status === 'completed').length,
failedGroups: this._schedule.groups.filter(g => g.status === 'failed').length,
partialGroups: this._schedule.groups.filter(g => g.status === 'partial').length,
totalTasks: this._schedule.totalTasks,
completedTasks: this._schedule.completedTasks,
failedTasks: this._schedule.failedTasks,
skippedTasks: this._schedule.groups.reduce((sum, g) => sum + g.skippedCount, 0),
};
}
/**
* Reset the scheduler.
*/
reset(): void {
this._schedule = null;
this._taskToGroup.clear();
}
}
// ========== Singleton ==========
let schedulerInstance: GroupScheduler | null = null;
/**
* Get or create the singleton GroupScheduler instance.
*/
export function getGroupScheduler(): GroupScheduler {
if (!schedulerInstance) {
schedulerInstance = new GroupScheduler();
}
return schedulerInstance;
}
/**
* Reset the singleton (for testing).
*/
export function resetGroupScheduler(): void {
if (schedulerInstance) {
schedulerInstance.reset();
}
schedulerInstance = null;
}