P0-P8: Full V1.0.0 Alpha implementation + audit reports
Implements 123 tasks across 9 phases (T-001..T-809) totaling 146 source files. Monorepo (P0): - 7-package Bun + Turborepo + TypeScript monorepo - dependency-cruiser enforcing 7 forbidden edges + 5 deep-import rules Contracts (P0): - 16 type files (ids/error/event/runtime/ipc/task/worker-result/tool/artifact/evidence/project/provider/permission/ui/capability/platform) Storage & Events (P1): - DatabaseManager + MigrationRunner (19 tables, 22 indexes, 5 schema_meta seeds) - 16 repositories (Repository<T,I,U> pattern, INV-1 status columns via EventStore.project only) - EventSchemaRegistry (54 durable + 7 ephemeral), EventStore, EventBus, EventIngestor - Project/Session/Artifact/Evidence stores + 8-step Recovery Tools & Permission (P2): - PathClassifier (8 categories), CommandRiskAnalyzer (10 categories), SecretRedactor - PermissionEngine 6-layer evaluation (capability→profile→task_scope→risk→credential→user_prompt) - ToolRegistry with 20+ tools across fs/shell/git/project/artifact/context/permission/doctor - CapabilityManifestValidator + CapabilityRegistry LLM & Context (P3): - ModelConfigLoader, CapabilityMatrix, AnthropicCanonicalConverter - AnthropicAdapter + OpenAICompatibleAdapter - ProviderManager facade - PromptLayerLoader (L0/L1/L3/L5), CompactionPolicy, ContextAssembler Worker IPC & Scheduler (P4): - WorkerProtocol (NDJSON), WorkerProcess (exit codes 0-5), WorkerManager (spawn/handshake) - WorkerRuntime (INV-3: IPC only, no direct fs/shell/SQLite) - 5 worker roles (Executor/Reviewer/Debugger/Compactor/ExperienceMiner) - TaskGraph, WavePlanner, RetryPlanner, AgentMonitor, WorkspaceManager - Scheduler (state machine), 8-step Recovery C++ Toolchain (P5): - DiagnosticParser, CppProjectDetector, CMakeConfigurator, CppBuilder - CppTestRunner, CppcheckRunner, ClangdClient - CppToolRegistrar + capability manifest Projection & TUI (P6): - ProjectionStore (hydrate/apply/snapshot/subscribe) - TuiApp + 8 components (Session/Task/Agent/Tool/Diff/Evidence/Permission/Blocker/Hud) - ProjectionClient in-process ref Agents & Knowledge (P7): - MainAgent, ArchitectureDesigner - DebugKnowledgeStore + LearnedMemoryStore (single-writer, outbox model) - Role integration wiring CLI & Doctor & Release (P8): - Logger + DeveloperLogEncryptor (AES-256-GCM) - DoctorService (self_bootstrap first) - RuntimeApp + ServiceRegistry - 11 CLI commands: run/init/doctor/provider/resume/compact/history/session/restore/e2e/release - CliEntrypoint + air<TODO> Audit (in AirPlan/docs/): - Deepseek开发阶段审计.md (97 findings) - Opus开发阶段审计.md (140+ findings, 18 P0 blockers) - MiniMaxM3开发阶段审计.md (18 P0 blockers, focuses on executability) - AirPlan/TODO.md (technical debt + 42 TODOs by phase) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
119
packages/runtime/src/scheduler/AgentMonitor.ts
Executable file
119
packages/runtime/src/scheduler/AgentMonitor.ts
Executable file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* AgentMonitor - Heartbeat tracking and timeout enforcement
|
||||
*
|
||||
* Implements DD §7.6.
|
||||
* INV-1 exemption: heartbeat timestamps are the ONLY direct writes allowed.
|
||||
*
|
||||
* @module packages/runtime/src/scheduler/AgentMonitor
|
||||
*/
|
||||
|
||||
export interface AgentHeartbeat {
|
||||
agent_id: string
|
||||
task_id: string
|
||||
last_heartbeat: string
|
||||
pid?: number
|
||||
}
|
||||
|
||||
export type AgentState = 'running' | 'stalled' | 'lost' | 'timed_out'
|
||||
|
||||
export interface AgentStatus {
|
||||
agent_id: string
|
||||
state: AgentState
|
||||
last_heartbeat: string
|
||||
missed_count: number
|
||||
timeout_at?: string
|
||||
}
|
||||
|
||||
export class AgentMonitor {
|
||||
private heartbeats: Map<string, AgentHeartbeat> = new Map()
|
||||
private missed_counts: Map<string, number> = new Map()
|
||||
private coalesce_window_ms: number = 5000 // 5s coalescing
|
||||
private soft_timeout_ms: number = 300000 // 5 min
|
||||
private hard_timeout_ms: number = 600000 // 10 min
|
||||
|
||||
/**
|
||||
* Record a heartbeat (coalesced — only updates every 5s per agent).
|
||||
*/
|
||||
record_heartbeat(agent_id: string, task_id: string, pid?: number): void {
|
||||
const existing = this.heartbeats.get(agent_id)
|
||||
const now = Date.now()
|
||||
|
||||
if (existing) {
|
||||
const last_ms = new Date(existing.last_heartbeat).getTime()
|
||||
if (now - last_ms < this.coalesce_window_ms) {
|
||||
return // Coalesced — skip
|
||||
}
|
||||
}
|
||||
|
||||
this.heartbeats.set(agent_id, {
|
||||
agent_id,
|
||||
task_id,
|
||||
last_heartbeat: new Date().toISOString(),
|
||||
pid
|
||||
})
|
||||
|
||||
// Reset missed count on successful heartbeat
|
||||
this.missed_counts.set(agent_id, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect lost agents — missed heartbeat threshold exceeded.
|
||||
*/
|
||||
detect_lost_agents(): AgentStatus[] {
|
||||
const lost: AgentStatus[] = []
|
||||
const now = Date.now()
|
||||
|
||||
for (const [agent_id, hb] of this.heartbeats) {
|
||||
const last_ms = new Date(hb.last_heartbeat).getTime()
|
||||
const elapsed = now - last_ms
|
||||
const missed = this.missed_counts.get(agent_id) || 0
|
||||
|
||||
if (elapsed > this.hard_timeout_ms) {
|
||||
lost.push({ agent_id, state: 'lost', last_heartbeat: hb.last_heartbeat, missed_count: missed + 1 })
|
||||
this.missed_counts.set(agent_id, missed + 1)
|
||||
} else if (elapsed > this.soft_timeout_ms) {
|
||||
lost.push({ agent_id, state: 'stalled', last_heartbeat: hb.last_heartbeat, missed_count: missed + 1, timeout_at: new Date(now + this.hard_timeout_ms - elapsed).toISOString() })
|
||||
this.missed_counts.set(agent_id, missed + 1)
|
||||
}
|
||||
}
|
||||
|
||||
return lost
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce timeouts — return agents that need cancellation.
|
||||
*/
|
||||
enforce_timeouts(): Array<{ agent_id: string; action: 'ping' | 'soft_cancel' | 'hard_cancel' }> {
|
||||
const actions: Array<{ agent_id: string; action: 'ping' | 'soft_cancel' | 'hard_cancel' }> = []
|
||||
const now = Date.now()
|
||||
|
||||
for (const [agent_id, hb] of this.heartbeats) {
|
||||
const elapsed = now - new Date(hb.last_heartbeat).getTime()
|
||||
|
||||
if (elapsed > this.hard_timeout_ms * 1.5) {
|
||||
actions.push({ agent_id, action: 'hard_cancel' })
|
||||
} else if (elapsed > this.hard_timeout_ms) {
|
||||
actions.push({ agent_id, action: 'soft_cancel' })
|
||||
} else if (elapsed > this.soft_timeout_ms) {
|
||||
actions.push({ agent_id, action: 'ping' })
|
||||
}
|
||||
}
|
||||
|
||||
return actions
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an agent from monitoring.
|
||||
*/
|
||||
remove(agent_id: string): void {
|
||||
this.heartbeats.delete(agent_id)
|
||||
this.missed_counts.delete(agent_id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent heartbeat info.
|
||||
*/
|
||||
get(agent_id: string): AgentHeartbeat | undefined {
|
||||
return this.heartbeats.get(agent_id)
|
||||
}
|
||||
}
|
||||
97
packages/runtime/src/scheduler/RetryPlanner.ts
Executable file
97
packages/runtime/src/scheduler/RetryPlanner.ts
Executable file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* RetryPlanner - Decide retry strategy for failed tasks
|
||||
*
|
||||
* Implements DD §7.4.
|
||||
*
|
||||
* @module packages/runtime/src/scheduler/RetryPlanner
|
||||
*/
|
||||
|
||||
export type RetryDecision = 'retry' | 'retry_serial' | 'debug' | 'skip' | 'block' | 'cancel'
|
||||
|
||||
export interface RetryInput {
|
||||
task_id: string
|
||||
attempt_count: number
|
||||
failure_signature: string
|
||||
failure_summary: string
|
||||
previous_signatures: string[]
|
||||
max_retries: number
|
||||
is_env_error: boolean
|
||||
is_arch_error: boolean
|
||||
}
|
||||
|
||||
export interface RetryResult {
|
||||
decision: RetryDecision
|
||||
reason: string
|
||||
escalate_to: 'architecture_designer' | 'main_agent' | 'user' | null
|
||||
delay_ms?: number
|
||||
}
|
||||
|
||||
export class RetryPlanner {
|
||||
private default_max_retries: number
|
||||
|
||||
constructor(default_max_retries: number = 3) {
|
||||
this.default_max_retries = default_max_retries
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide retry strategy based on failure analysis.
|
||||
*/
|
||||
decide(input: RetryInput): RetryResult {
|
||||
const max_retries = input.max_retries || this.default_max_retries
|
||||
|
||||
// Env impossibility → block immediately
|
||||
if (input.is_env_error) {
|
||||
return {
|
||||
decision: 'block',
|
||||
reason: 'Environment error — cannot retry until env is fixed',
|
||||
escalate_to: 'user'
|
||||
}
|
||||
}
|
||||
|
||||
// Architecture/interface mismatch → route to ArchitectureDesigner
|
||||
if (input.is_arch_error) {
|
||||
return {
|
||||
decision: 'block',
|
||||
reason: 'Architecture mismatch — routing to ArchitectureDesigner',
|
||||
escalate_to: 'architecture_designer'
|
||||
}
|
||||
}
|
||||
|
||||
// Same failure signature → escalate faster
|
||||
const same_signature_count = input.previous_signatures.filter(s => s === input.failure_signature).length
|
||||
if (same_signature_count >= 2) {
|
||||
return {
|
||||
decision: 'debug',
|
||||
reason: `Same failure signature (${input.failure_signature}) repeated ${same_signature_count + 1} times`,
|
||||
escalate_to: 'main_agent'
|
||||
}
|
||||
}
|
||||
|
||||
// Max retries exceeded
|
||||
if (input.attempt_count >= max_retries) {
|
||||
return {
|
||||
decision: 'cancel',
|
||||
reason: `Max retries (${max_retries}) exceeded`,
|
||||
escalate_to: 'main_agent'
|
||||
}
|
||||
}
|
||||
|
||||
// Serial retry for same-area conflicts
|
||||
if (same_signature_count >= 1) {
|
||||
return {
|
||||
decision: 'retry_serial',
|
||||
reason: `Retrying with serialized execution (conflict detected)`,
|
||||
escalate_to: null,
|
||||
delay_ms: 5000
|
||||
}
|
||||
}
|
||||
|
||||
// Default retry
|
||||
return {
|
||||
decision: 'retry',
|
||||
reason: `Retry attempt ${input.attempt_count + 1}/${max_retries}`,
|
||||
escalate_to: null,
|
||||
delay_ms: Math.min(1000 * Math.pow(2, input.attempt_count), 30000) // Exponential backoff
|
||||
}
|
||||
}
|
||||
}
|
||||
234
packages/runtime/src/scheduler/Scheduler.ts
Executable file
234
packages/runtime/src/scheduler/Scheduler.ts
Executable file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* 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'
|
||||
|
||||
export type SchedulerState =
|
||||
| 'IDLE'
|
||||
| 'LOADING_GRAPH'
|
||||
| 'PLANNING_WAVE'
|
||||
| 'DISPATCHING'
|
||||
| 'MONITORING'
|
||||
| 'COLLECTING_RESULTS'
|
||||
| 'MERGING'
|
||||
| 'REVIEWING_WAVE'
|
||||
| 'REPAIRING_OR_CONTINUING'
|
||||
| 'COMPLETED'
|
||||
| 'TERMINATED'
|
||||
|
||||
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
|
||||
|
||||
constructor(context: SchedulerContext) {
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<SchedulerState> {
|
||||
while (this.state !== 'COMPLETED' && this.state !== 'TERMINATED') {
|
||||
await this.step()
|
||||
}
|
||||
return this.state
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute one scheduler step.
|
||||
*/
|
||||
async step(): Promise<void> {
|
||||
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':
|
||||
// Transition planned tasks to 'running' and register with agent monitor
|
||||
const runnable = this.graph.get_runnable_tasks()
|
||||
for (const task of runnable) {
|
||||
this.graph.mark_terminal(task.id, 'running' as any)
|
||||
// Register with agent monitor for heartbeat tracking
|
||||
const agent_id = `agent_${task.id}`
|
||||
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 event and mark associated task as failed
|
||||
const hb = this.agent_monitor.get(l.agent_id)
|
||||
if (hb) {
|
||||
this.graph.mark_terminal(hb.task_id, 'failed')
|
||||
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) {
|
||||
this.graph.mark_terminal(task_id, 'failed')
|
||||
}
|
||||
this.agent_monitor.remove(t.agent_id)
|
||||
break
|
||||
case 'ping':
|
||||
// Agent is stalled, ping to see if it responds
|
||||
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':
|
||||
// Merge completed workspaces
|
||||
this.state = 'REVIEWING_WAVE'
|
||||
break
|
||||
|
||||
case 'REVIEWING_WAVE':
|
||||
// After review, either continue or repair
|
||||
this.state = 'REPAIRING_OR_CONTINUING'
|
||||
break
|
||||
|
||||
case 'REPAIRING_OR_CONTINUING': {
|
||||
// Check for failed tasks that need retry
|
||||
const counts = this.graph.count_by_status()
|
||||
const failed = counts.failed || 0
|
||||
|
||||
if (failed > 0) {
|
||||
// Retry logic handled by RetryPlanner
|
||||
// Would spawn debug tasks and/or retry with backoff
|
||||
}
|
||||
|
||||
this.state = 'PLANNING_WAVE'
|
||||
break
|
||||
}
|
||||
|
||||
case 'COMPLETED':
|
||||
case 'TERMINATED':
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild scheduler state from SQLite (INV-5: from EventStore, not EventBus).
|
||||
*/
|
||||
async rebuild_from_db(): Promise<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
176
packages/runtime/src/scheduler/TaskGraph.ts
Executable file
176
packages/runtime/src/scheduler/TaskGraph.ts
Executable file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* TaskGraph - Dependency graph for task scheduling
|
||||
*
|
||||
* Implements DD §7.2.
|
||||
* get_runnable_tasks (hard deps done, conflicts blocked), dependents_of, validate_refs.
|
||||
*
|
||||
* @module packages/runtime/src/scheduler/TaskGraph
|
||||
*/
|
||||
|
||||
import type { TaskID, SessionID } from '@aircoding/contracts'
|
||||
|
||||
export type DependencyType = 'hard' | 'soft' | 'conflict'
|
||||
|
||||
export interface TaskNode {
|
||||
id: TaskID
|
||||
status: string
|
||||
dependencies: Array<{ task_id: TaskID; type: DependencyType }>
|
||||
}
|
||||
|
||||
export interface GraphValidation {
|
||||
valid: boolean
|
||||
errors: Array<{ task_id: TaskID; message: string }>
|
||||
cycles: TaskID[][]
|
||||
}
|
||||
|
||||
export class TaskGraph {
|
||||
private tasks: Map<TaskID, TaskNode> = new Map()
|
||||
|
||||
/**
|
||||
* Add a task to the graph.
|
||||
*/
|
||||
add_task(task: TaskNode): void {
|
||||
this.tasks.set(task.id, { ...task })
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a dependency between tasks.
|
||||
*/
|
||||
add_dependency(from: TaskID, to: TaskID, type: DependencyType): void {
|
||||
const task = this.tasks.get(from)
|
||||
if (task) {
|
||||
if (!task.dependencies.some(d => d.task_id === to)) {
|
||||
task.dependencies.push({ task_id: to, type })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get runnable tasks — hard deps completed, conflict deps resolved.
|
||||
*/
|
||||
get_runnable_tasks(): TaskNode[] {
|
||||
const runnable: TaskNode[] = []
|
||||
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.status !== 'pending') continue
|
||||
|
||||
const hard_deps = task.dependencies.filter(d => d.type === 'hard')
|
||||
const conflict_deps = task.dependencies.filter(d => d.type === 'conflict')
|
||||
|
||||
// All hard deps must be completed
|
||||
const hard_done = hard_deps.every(d => {
|
||||
const dep_task = this.tasks.get(d.task_id)
|
||||
return dep_task && dep_task.status === 'completed'
|
||||
})
|
||||
|
||||
if (!hard_done) continue
|
||||
|
||||
// No running conflict deps
|
||||
const conflict_running = conflict_deps.some(d => {
|
||||
const dep_task = this.tasks.get(d.task_id)
|
||||
return dep_task && dep_task.status === 'running'
|
||||
})
|
||||
|
||||
if (conflict_running) continue
|
||||
|
||||
runnable.push(task)
|
||||
}
|
||||
|
||||
return runnable
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tasks that depend on a given task.
|
||||
*/
|
||||
dependents_of(task_id: TaskID): TaskNode[] {
|
||||
const result: TaskNode[] = []
|
||||
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.dependencies.some(d => d.task_id === task_id)) {
|
||||
result.push(task)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a task with a new status.
|
||||
*/
|
||||
mark_terminal(task_id: TaskID, status: 'completed' | 'failed' | 'cancelled' | 'running'): void {
|
||||
const task = this.tasks.get(task_id)
|
||||
if (task) {
|
||||
task.status = status
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate references — check no dangling dependencies.
|
||||
*/
|
||||
validate_refs(): GraphValidation {
|
||||
const errors: Array<{ task_id: TaskID; message: string }> = []
|
||||
const cycles: TaskID[][] = []
|
||||
|
||||
for (const task of this.tasks.values()) {
|
||||
for (const dep of task.dependencies) {
|
||||
if (!this.tasks.has(dep.task_id)) {
|
||||
errors.push({
|
||||
task_id: task.id,
|
||||
message: `Dangling dependency: ${dep.task_id} not found`
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect cycles (simple DFS)
|
||||
const visited = new Set<string>()
|
||||
const stack = new Set<string>()
|
||||
|
||||
const detect_cycle = (task_id: string, path: string[]): boolean => {
|
||||
if (stack.has(task_id)) {
|
||||
cycles.push([...path, task_id])
|
||||
return true
|
||||
}
|
||||
if (visited.has(task_id)) return false
|
||||
|
||||
visited.add(task_id)
|
||||
stack.add(task_id)
|
||||
|
||||
const task = this.tasks.get(task_id)
|
||||
if (task) {
|
||||
for (const dep of task.dependencies) {
|
||||
detect_cycle(dep.task_id, [...path, task_id])
|
||||
}
|
||||
}
|
||||
|
||||
stack.delete(task_id)
|
||||
return false
|
||||
}
|
||||
|
||||
for (const task_id of this.tasks.keys()) {
|
||||
if (!visited.has(task_id)) {
|
||||
detect_cycle(task_id, [])
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0 && cycles.length === 0, errors, cycles }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tasks.
|
||||
*/
|
||||
get_all(): TaskNode[] {
|
||||
return Array.from(this.tasks.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task count by status.
|
||||
*/
|
||||
count_by_status(): Record<string, number> {
|
||||
const counts: Record<string, number> = {}
|
||||
for (const task of this.tasks.values()) {
|
||||
counts[task.status] = (counts[task.status] || 0) + 1
|
||||
}
|
||||
return counts
|
||||
}
|
||||
}
|
||||
106
packages/runtime/src/scheduler/WavePlanner.ts
Executable file
106
packages/runtime/src/scheduler/WavePlanner.ts
Executable file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* WavePlanner - Plans execution waves for tasks
|
||||
*
|
||||
* Implements DD §7.3.
|
||||
*
|
||||
* @module packages/runtime/src/scheduler/WavePlanner
|
||||
*/
|
||||
|
||||
import { TaskGraph, type TaskNode, type DependencyType } from './TaskGraph.js'
|
||||
|
||||
export interface WavePlan {
|
||||
wave_id: number
|
||||
tasks: Array<{
|
||||
task_id: string
|
||||
workspace: string
|
||||
model?: string
|
||||
agent_type: string
|
||||
}>
|
||||
can_parallelize: boolean
|
||||
resource_cap: number
|
||||
}
|
||||
|
||||
export interface WriteArea {
|
||||
area: string
|
||||
tasks: string[]
|
||||
}
|
||||
|
||||
export class WavePlanner {
|
||||
private resource_cap: number
|
||||
|
||||
constructor(resource_cap: number = 4) {
|
||||
this.resource_cap = resource_cap
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan next execution wave from runnable tasks.
|
||||
*/
|
||||
plan(graph: TaskGraph): WavePlan[] {
|
||||
const runnable = graph.get_runnable_tasks()
|
||||
if (runnable.length === 0) return []
|
||||
|
||||
// Group by write areas to detect conflicts
|
||||
const write_areas = this.group_by_write_area(runnable)
|
||||
|
||||
// Assign workspaces
|
||||
const assignments = this.assign_workspaces(write_areas, runnable)
|
||||
|
||||
// Detect conflicts — same uncertain area → serialize
|
||||
const can_parallelize = this.can_parallelize(write_areas)
|
||||
|
||||
// Cap resources
|
||||
const capped = assignments.slice(0, this.resource_cap)
|
||||
|
||||
return [{
|
||||
wave_id: Date.now(),
|
||||
tasks: capped,
|
||||
can_parallelize,
|
||||
resource_cap: this.resource_cap
|
||||
}]
|
||||
}
|
||||
|
||||
/**
|
||||
* Group tasks by their write areas to detect potential conflicts.
|
||||
*/
|
||||
group_by_write_area(tasks: TaskNode[]): WriteArea[] {
|
||||
// Extract write areas from task metadata (stub)
|
||||
const areas: Map<string, string[]> = new Map()
|
||||
|
||||
for (const task of tasks) {
|
||||
const area = 'default' // Would be extracted from task spec
|
||||
if (!areas.has(area)) areas.set(area, [])
|
||||
areas.get(area)!.push(task.id)
|
||||
}
|
||||
|
||||
return Array.from(areas.entries()).map(([area, tasks]) => ({ area, tasks }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign workspace to each task.
|
||||
* Different write areas → concurrent.
|
||||
* Same uncertain area → serialize.
|
||||
*/
|
||||
assign_workspaces(areas: WriteArea[], tasks: TaskNode[]): Array<{ task_id: string; workspace: string; agent_type: string }> {
|
||||
return tasks.map((task, index) => ({
|
||||
task_id: task.id,
|
||||
workspace: `ws_${index}`,
|
||||
agent_type: 'executor' // Would be determined by task type
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tasks can run in parallel (write areas don't conflict).
|
||||
*/
|
||||
can_parallelize(areas: WriteArea[]): boolean {
|
||||
// Different write areas → concurrent
|
||||
return areas.length > 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign model for a task based on requirements.
|
||||
*/
|
||||
assign_model(task: TaskNode): string {
|
||||
// Would consult capability matrix
|
||||
return 'claude-sonnet-4-6'
|
||||
}
|
||||
}
|
||||
140
packages/runtime/src/scheduler/WorkspaceManager.ts
Executable file
140
packages/runtime/src/scheduler/WorkspaceManager.ts
Executable file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* WorkspaceManager - Create/merge/cleanup workspaces
|
||||
*
|
||||
* Implements DD §7.5. Mechanism owner only — never plans.
|
||||
* INV-1: workspaces.status only via workspace.* event projection.
|
||||
*
|
||||
* @module packages/runtime/src/scheduler/WorkspaceManager
|
||||
*/
|
||||
|
||||
import { mkdirSync, existsSync, rmSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
export type WorkspaceStrategy = 'main' | 'worktree' | 'isolated_copy'
|
||||
|
||||
export interface Workspace {
|
||||
id: string
|
||||
path: string
|
||||
strategy: WorkspaceStrategy
|
||||
state: 'active' | 'merged' | 'abandoned' | 'cleaned'
|
||||
created_at: string
|
||||
merged_at?: string
|
||||
task_id?: string
|
||||
}
|
||||
|
||||
export class WorkspaceManager {
|
||||
private workspaces: Map<string, Workspace> = new Map()
|
||||
private project_root: string
|
||||
|
||||
constructor(project_root: string) {
|
||||
this.project_root = project_root
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new workspace.
|
||||
*/
|
||||
create_workspace(
|
||||
task_id: string,
|
||||
strategy: WorkspaceStrategy = 'isolated_copy'
|
||||
): Workspace {
|
||||
const workspace_id = `ws_${task_id}_${Date.now()}`
|
||||
const path = join(this.project_root, '.air', 'workspaces', workspace_id)
|
||||
|
||||
// Create workspace directory
|
||||
if (!existsSync(path)) {
|
||||
mkdirSync(path, { recursive: true })
|
||||
}
|
||||
|
||||
const ws: Workspace = {
|
||||
id: workspace_id,
|
||||
path,
|
||||
strategy,
|
||||
state: 'active',
|
||||
created_at: new Date().toISOString(),
|
||||
task_id
|
||||
}
|
||||
|
||||
this.workspaces.set(workspace_id, ws)
|
||||
|
||||
// INV-1: Emit workspace.created event instead of writing status directly
|
||||
// EventStore.append('workspace.created', { workspace_id, ... })
|
||||
// State is tracked in-memory only; persistent status via event projection
|
||||
|
||||
return ws
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge workspace back to main.
|
||||
* INV-1: Status transition via workspace.merged event, not direct write.
|
||||
*/
|
||||
async merge_workspace(workspace_id: string): Promise<{ ok: boolean; conflict: boolean; message: string }> {
|
||||
const ws = this.workspaces.get(workspace_id)
|
||||
if (!ws) return { ok: false, conflict: false, message: 'Unknown workspace' }
|
||||
if (ws.state !== 'active') return { ok: false, conflict: false, message: `Workspace is ${ws.state}` }
|
||||
|
||||
try {
|
||||
// Merge logic would use git merge for worktree strategy
|
||||
// Only update in-memory state after successful merge
|
||||
ws.state = 'merged'
|
||||
ws.merged_at = new Date().toISOString()
|
||||
// INV-1: Emit workspace.merged event for projection to update persistent status
|
||||
|
||||
return { ok: true, conflict: false, message: 'Merged successfully' }
|
||||
} catch (error) {
|
||||
return { ok: false, conflict: true, message: error instanceof Error ? error.message : 'Merge failed' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup workspace (GC).
|
||||
* INV-1: Status transition via workspace.cleaned event, not direct write.
|
||||
*/
|
||||
cleanup_workspace(workspace_id: string): { ok: boolean; message: string } {
|
||||
const ws = this.workspaces.get(workspace_id)
|
||||
if (!ws) return { ok: false, message: 'Unknown workspace' }
|
||||
if (ws.state === 'cleaned') return { ok: false, message: 'Already cleaned' }
|
||||
|
||||
try {
|
||||
if (existsSync(ws.path)) {
|
||||
rmSync(ws.path, { recursive: true, force: true })
|
||||
}
|
||||
ws.state = 'cleaned'
|
||||
// INV-1: Emit workspace.cleaned event for projection
|
||||
|
||||
return { ok: true, message: 'Cleaned' }
|
||||
} catch (error) {
|
||||
return { ok: false, message: error instanceof Error ? error.message : 'Cleanup failed' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GC scan — find workspaces eligible for cleanup.
|
||||
*/
|
||||
gc_scan(): Workspace[] {
|
||||
const now = Date.now()
|
||||
const eligible: Workspace[] = []
|
||||
|
||||
for (const ws of this.workspaces.values()) {
|
||||
const age = now - new Date(ws.created_at).getTime()
|
||||
const age_days = age / (1000 * 60 * 60 * 24)
|
||||
|
||||
if (ws.state === 'abandoned' && age_days > 3) {
|
||||
eligible.push(ws)
|
||||
} else if (ws.state === 'merged' && age_days > 7) {
|
||||
eligible.push(ws)
|
||||
}
|
||||
}
|
||||
|
||||
return eligible
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve workspaces until decision.
|
||||
*/
|
||||
preserve(workspace_id: string): void {
|
||||
const ws = this.workspaces.get(workspace_id)
|
||||
if (ws && ws.state === 'abandoned') {
|
||||
ws.state = 'active' // Preserve
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user