chore: push all design docs, V2 plan specs, and current working state
Includes AirPlan design documents, AircOding-alpha1-plan, AirPlanV2, AirPlan-ParaV2, AirPlan-Para V1 reference docs, and all working code changes across packages. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
18
packages/scheduler/package.json
Executable file
18
packages/scheduler/package.json
Executable file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@aircoding/scheduler",
|
||||
"version": "1.0.0-alpha.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aircoding/contracts": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.1",
|
||||
"typescript": "^5.8.0"
|
||||
}
|
||||
}
|
||||
119
packages/scheduler/src/AgentMonitor.ts
Executable file
119
packages/scheduler/src/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/scheduler/src/RetryPlanner.ts
Executable file
97
packages/scheduler/src/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
|
||||
}
|
||||
}
|
||||
}
|
||||
567
packages/scheduler/src/Scheduler.ts
Executable file
567
packages/scheduler/src/Scheduler.ts
Executable file
@@ -0,0 +1,567 @@
|
||||
/**
|
||||
* Scheduler — Pure scheduling engine (stripped of runtime imports)
|
||||
*
|
||||
* Implements contracts §9; DD §7.1 + state machine §20.2.
|
||||
* All side effects (event emission, worker spawn) are injected as callbacks.
|
||||
*
|
||||
* @module packages/scheduler/src/Scheduler
|
||||
*/
|
||||
|
||||
import type { TaskID, SessionID, ProjectID } from '@aircoding/contracts'
|
||||
import { TaskGraph } from './TaskGraph.js'
|
||||
import type { CascadeReport } 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'
|
||||
| 'FROZEN'
|
||||
| 'COMPLETED'
|
||||
| 'BLOCKED'
|
||||
| 'CANCELLED'
|
||||
|
||||
export interface SchedulerContext {
|
||||
session_id: SessionID
|
||||
project_id: ProjectID
|
||||
project_root: string
|
||||
}
|
||||
|
||||
/** Task specification passed to create_tasks */
|
||||
export interface TaskSpecInput {
|
||||
id: TaskID
|
||||
type: string
|
||||
title: string
|
||||
description?: string
|
||||
depends_on?: string[]
|
||||
task_spec?: Record<string, unknown>
|
||||
adr_refs?: string[]
|
||||
acceptance_criteria?: string[]
|
||||
}
|
||||
|
||||
/** Callback: emit a durable event (implemented by runtime/EventIngestor) */
|
||||
export type EventEmitter = (event: {
|
||||
type: string
|
||||
payload: Record<string, unknown>
|
||||
task_id?: TaskID
|
||||
agent_id?: string
|
||||
}) => Promise<void>
|
||||
|
||||
/** Callback: spawn a worker process (implemented by runtime/WorkerManager) */
|
||||
export type WorkerSpawner = (task: {
|
||||
id: TaskID
|
||||
type: string
|
||||
title: string
|
||||
description: string
|
||||
task_spec: Record<string, unknown>
|
||||
agent_id: string
|
||||
}) => Promise<void>
|
||||
|
||||
/** Callback: get worker result for a task */
|
||||
export type ResultGetter = (task_id: TaskID) => {
|
||||
status: string
|
||||
summary: string
|
||||
changed_files: string[]
|
||||
evidence_refs: string[]
|
||||
} | null
|
||||
|
||||
/** Callback: check if any workers are still running */
|
||||
export type RunningChecker = () => boolean
|
||||
|
||||
export interface SchedulerCallbacks {
|
||||
emit_event: EventEmitter
|
||||
spawn_worker: WorkerSpawner
|
||||
get_result: ResultGetter
|
||||
has_running: RunningChecker
|
||||
/** Optional: rebuild task list from DB */
|
||||
rebuild_from_db?: () => Promise<Array<{ id: TaskID; status: string; type: string; title: string; description?: string; depends_on?: 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 cb: SchedulerCallbacks
|
||||
|
||||
// FR-007: Retry tracking
|
||||
private retry_attempts?: Map<TaskID, number>
|
||||
private retry_signatures?: Map<TaskID, string[]>
|
||||
private failed_task_errors?: Map<TaskID, string>
|
||||
|
||||
constructor(context: SchedulerContext, callbacks: SchedulerCallbacks) {
|
||||
this.context = context
|
||||
this.cb = callbacks
|
||||
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()
|
||||
}
|
||||
|
||||
get_state(): SchedulerState { return this.state }
|
||||
get_graph(): TaskGraph { return this.graph }
|
||||
|
||||
// =========================================================================
|
||||
// Task creation
|
||||
// =========================================================================
|
||||
|
||||
async create_tasks(tasks: TaskSpecInput[]): Promise<void> {
|
||||
for (const task of tasks) {
|
||||
this.graph.add_task({
|
||||
id: task.id,
|
||||
status: 'pending',
|
||||
type: task.type,
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
task_spec: task.task_spec,
|
||||
adr_refs: task.adr_refs,
|
||||
acceptance_criteria: task.acceptance_criteria,
|
||||
dependencies: task.depends_on?.map(d => ({ task_id: d, type: 'hard' as const })) || [],
|
||||
})
|
||||
|
||||
await this.cb.emit_event({
|
||||
type: 'task.created',
|
||||
task_id: task.id,
|
||||
payload: {
|
||||
task_id: task.id,
|
||||
type: task.type,
|
||||
title: task.title,
|
||||
task_spec_json: task.task_spec || { description: task.description || '' },
|
||||
dependencies: (task.depends_on || []).map(d => ({ depends_on_task_id: d, dependency_type: 'hard', reason: '' })),
|
||||
metadata: {},
|
||||
}
|
||||
})
|
||||
}
|
||||
this.state = 'PLANNING_WAVE'
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ADR cascade invalidation (FR-007.5)
|
||||
// =========================================================================
|
||||
|
||||
async invalidate_by_adr(adr_id: string, reason: string): Promise<CascadeReport> {
|
||||
const delta = {
|
||||
removed_tasks: [] as string[],
|
||||
added_tasks: [] as any[],
|
||||
modified_tasks: [] as any[],
|
||||
edge_changes: [] as any[],
|
||||
reason,
|
||||
}
|
||||
|
||||
const report = this.graph.invalidate_by_adr(adr_id, delta, this.context.project_root)
|
||||
|
||||
// Emit invalidated events for each affected task
|
||||
for (const [id, task] of this.graph.get_all().reduce((m, t) => { m.set(t.id, t); return m }, new Map<string, any>())) {
|
||||
if (task.status === 'invalidated') {
|
||||
await this.cb.emit_event({
|
||||
type: 'task.invalidated',
|
||||
task_id: id,
|
||||
payload: { task_id: id, adr_id, reason, rollback_ref: report.rollback_ref },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this.state = 'FROZEN'
|
||||
return report
|
||||
}
|
||||
|
||||
async apply_plan_delta(delta: {
|
||||
removed_tasks: string[]
|
||||
added_tasks: Array<{ id: TaskID; type: string; title: string; description?: string; dependencies?: Array<{ depends_on_task_id: string; dependency_type: string }> }>
|
||||
modified_tasks: Array<{ id: TaskID; title?: string; description?: string; dependencies?: Array<{ depends_on_task_id: string; dependency_type: string }> }>
|
||||
edge_changes: Array<{ task_id: string; depends_on_task_id: string; dependency_type: string; action: 'add' | 'remove' }>
|
||||
reason: string
|
||||
}): Promise<{ removed: number; added: number; modified: number; skipped: string[] }> {
|
||||
const result = this.graph.apply_delta(delta as any)
|
||||
|
||||
for (const id of delta.removed_tasks) {
|
||||
await this.cb.emit_event({
|
||||
type: 'task.removed',
|
||||
task_id: id,
|
||||
payload: { task_id: id, reason: delta.reason, removed_by: 'architecture_designer' },
|
||||
})
|
||||
}
|
||||
for (const t of delta.added_tasks) {
|
||||
await this.cb.emit_event({
|
||||
type: 'task.created',
|
||||
task_id: t.id,
|
||||
payload: {
|
||||
task_id: t.id, type: t.type, title: t.title,
|
||||
task_spec_json: { description: t.description || '' },
|
||||
dependencies: (t.dependencies || []).map(d => ({ depends_on_task_id: d.depends_on_task_id, dependency_type: d.dependency_type, reason: delta.reason })),
|
||||
metadata: {},
|
||||
},
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
unfreeze(): void {
|
||||
this.graph.dispatch_frozen = false
|
||||
if (this.state === 'FROZEN') {
|
||||
this.state = 'PLANNING_WAVE'
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// State machine
|
||||
// =========================================================================
|
||||
|
||||
async run_until_idle(): Promise<SchedulerState> {
|
||||
while (
|
||||
this.state !== 'COMPLETED' &&
|
||||
this.state !== 'BLOCKED' &&
|
||||
this.state !== 'CANCELLED' &&
|
||||
this.state !== 'FROZEN'
|
||||
) {
|
||||
await this.step()
|
||||
}
|
||||
return this.state
|
||||
}
|
||||
|
||||
async step(): Promise<void> {
|
||||
switch (this.state) {
|
||||
case 'IDLE':
|
||||
this.state = 'LOADING_GRAPH'
|
||||
break
|
||||
|
||||
case 'LOADING_GRAPH': {
|
||||
const validation = this.graph.validate_refs()
|
||||
if (!validation.valid) {
|
||||
console.error('Graph validation failed:', validation.errors)
|
||||
this.state = 'BLOCKED'
|
||||
return
|
||||
}
|
||||
this.state = 'PLANNING_WAVE'
|
||||
break
|
||||
}
|
||||
|
||||
case 'PLANNING_WAVE': {
|
||||
const counts = this.graph.count_by_status()
|
||||
const pending = counts.pending || 0
|
||||
const running = counts.running || 0
|
||||
|
||||
if (pending === 0 && running === 0) {
|
||||
this.state = this.terminal_state_from_counts(counts)
|
||||
return
|
||||
}
|
||||
|
||||
if (running > 0) {
|
||||
this.state = 'MONITORING'
|
||||
return
|
||||
}
|
||||
|
||||
const plan = this.wave_planner.plan(this.graph)
|
||||
if (plan.length === 0) {
|
||||
this.state = pending > 0 ? 'REPAIRING_OR_CONTINUING' : this.terminal_state_from_counts(counts)
|
||||
return
|
||||
}
|
||||
|
||||
this.state = 'DISPATCHING'
|
||||
break
|
||||
}
|
||||
|
||||
case 'DISPATCHING': {
|
||||
if (this.graph.dispatch_frozen) {
|
||||
this.state = 'FROZEN'
|
||||
break
|
||||
}
|
||||
const runnable = this.graph.get_runnable_tasks()
|
||||
for (const task of runnable) {
|
||||
const agent_id = `agent_${task.id}_${Date.now()}`
|
||||
const timestamp = Date.now()
|
||||
|
||||
await this.cb.emit_event({
|
||||
type: 'task.started',
|
||||
task_id: task.id,
|
||||
agent_id,
|
||||
payload: {
|
||||
task_id: task.id, agent_id,
|
||||
attempt_id: `${task.id}_${timestamp}`,
|
||||
attempt_index: 0,
|
||||
workspace_id: `ws_${task.id}_${timestamp}`,
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await this.cb.spawn_worker({
|
||||
id: task.id,
|
||||
type: task.type || 'execute',
|
||||
title: task.title || task.id,
|
||||
description: task.description || '',
|
||||
task_spec: task.task_spec || {
|
||||
id: task.id,
|
||||
title: task.title || task.id,
|
||||
description: task.description || '',
|
||||
acceptance_criteria: task.acceptance_criteria || ['Task completed successfully'],
|
||||
},
|
||||
agent_id,
|
||||
})
|
||||
this.graph.update_status(task.id, 'running')
|
||||
this.agent_monitor.record_heartbeat(agent_id, task.id)
|
||||
|
||||
await this.cb.emit_event({
|
||||
type: 'agent.started',
|
||||
agent_id,
|
||||
task_id: task.id,
|
||||
payload: {
|
||||
agent_id,
|
||||
agent_type: task.type || 'executor',
|
||||
task_id: task.id,
|
||||
pid: 0,
|
||||
model_provider_id: '',
|
||||
model_id: '',
|
||||
workspace_id: `ws_${task.id}`,
|
||||
metadata: {},
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
await this.cb.emit_event({
|
||||
type: 'task.failed',
|
||||
task_id: task.id,
|
||||
agent_id,
|
||||
payload: {
|
||||
task_id: task.id, agent_id,
|
||||
attempt_id: `${task.id}_${Date.now()}`,
|
||||
error: { message: 'Worker spawn failed' },
|
||||
evidence_refs: [], metadata: {},
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
this.state = 'MONITORING'
|
||||
break
|
||||
}
|
||||
|
||||
case 'MONITORING': {
|
||||
// Agent health
|
||||
const lost = this.agent_monitor.detect_lost_agents()
|
||||
for (const l of lost) {
|
||||
const hb = this.agent_monitor.get(l.agent_id)
|
||||
if (hb) {
|
||||
await this.cb.emit_event({
|
||||
type: 'agent.lost',
|
||||
task_id: hb.task_id,
|
||||
payload: { agent_id: l.agent_id, task_id: hb.task_id, last_heartbeat_at: l.last_heartbeat, detection_reason: l.state },
|
||||
})
|
||||
this.agent_monitor.remove(l.agent_id)
|
||||
this.graph.update_status(hb.task_id, 'failed')
|
||||
}
|
||||
}
|
||||
|
||||
// Timeouts
|
||||
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
|
||||
if (task_id && (t.action === 'hard_cancel' || t.action === 'soft_cancel')) {
|
||||
await this.cb.emit_event({
|
||||
type: 'agent.cancelled',
|
||||
task_id,
|
||||
payload: { agent_id: t.agent_id, task_id, reason: t.action },
|
||||
})
|
||||
this.agent_monitor.remove(t.agent_id)
|
||||
this.graph.update_status(task_id, 'cancelled')
|
||||
}
|
||||
}
|
||||
|
||||
// Worker results
|
||||
const running_tasks = this.graph.get_tasks_by_status('running')
|
||||
for (const task of running_tasks) {
|
||||
const result = this.cb.get_result(task.id)
|
||||
if (!result) continue
|
||||
|
||||
if (result.status === 'completed') {
|
||||
await this.cb.emit_event({
|
||||
type: 'task.completed',
|
||||
task_id: task.id,
|
||||
payload: {
|
||||
task_id: task.id,
|
||||
summary: result.summary,
|
||||
changed_files: result.changed_files,
|
||||
evidence_refs: result.evidence_refs,
|
||||
}
|
||||
})
|
||||
this.graph.update_status(task.id, 'completed')
|
||||
} else if (result.status === 'blocked') {
|
||||
await this.cb.emit_event({
|
||||
type: 'task.blocked',
|
||||
task_id: task.id,
|
||||
payload: { task_id: task.id, reason: result.summary, blocker_kind: 'worker_blocked', evidence_refs: result.evidence_refs, suggested_next_step: 'Review and retry' },
|
||||
})
|
||||
this.graph.update_status(task.id, 'blocked')
|
||||
} else if (result.status === 'cancelled') {
|
||||
await this.cb.emit_event({
|
||||
type: 'task.cancelled',
|
||||
task_id: task.id,
|
||||
payload: { task_id: task.id, reason: result.summary, cancelled_by: 'worker' },
|
||||
})
|
||||
this.graph.update_status(task.id, 'cancelled')
|
||||
} else {
|
||||
// failed or other
|
||||
await this.cb.emit_event({
|
||||
type: 'task.failed',
|
||||
task_id: task.id,
|
||||
payload: { task_id: task.id, error: { message: result.summary }, evidence_refs: result.evidence_refs, metadata: { worker_status: result.status } },
|
||||
})
|
||||
this.graph.update_status(task.id, 'failed')
|
||||
this.record_task_failure(task.id, result.summary)
|
||||
}
|
||||
}
|
||||
|
||||
// Give event loop time for IPC
|
||||
if (this.cb.has_running()) {
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
}
|
||||
|
||||
if ((this.graph.count_by_status().running || 0) === 0) {
|
||||
this.state = 'COLLECTING_RESULTS'
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'COLLECTING_RESULTS':
|
||||
this.state = 'MERGING'
|
||||
break
|
||||
|
||||
case 'FROZEN':
|
||||
// Wait for external unfreeze
|
||||
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 failed_tasks = this.graph.get_tasks_by_status('failed')
|
||||
if (failed_tasks.length > 0) {
|
||||
if (!this.retry_attempts) this.retry_attempts = new Map()
|
||||
|
||||
for (const task of failed_tasks) {
|
||||
const attempts = this.retry_attempts.get(task.id) || 0
|
||||
const signatures = this.retry_signatures?.get(task.id) || []
|
||||
const decision = this.retry_planner.decide({
|
||||
task_id: task.id,
|
||||
attempt_count: attempts,
|
||||
failure_signature: this.get_failure_signature(task),
|
||||
failure_summary: task.description || `Task ${task.id} failed`,
|
||||
previous_signatures: signatures,
|
||||
max_retries: 3,
|
||||
is_env_error: false,
|
||||
is_arch_error: false,
|
||||
})
|
||||
|
||||
switch (decision.decision) {
|
||||
case 'retry':
|
||||
case 'retry_serial':
|
||||
this.graph.update_status(task.id, 'pending')
|
||||
this.retry_attempts.set(task.id, attempts + 1)
|
||||
if (!this.retry_signatures) this.retry_signatures = new Map()
|
||||
this.retry_signatures.set(task.id, [...signatures, this.get_failure_signature(task)])
|
||||
break
|
||||
case 'skip':
|
||||
this.graph.update_status(task.id, 'completed')
|
||||
this.retry_attempts.delete(task.id)
|
||||
break
|
||||
case 'block':
|
||||
this.graph.update_status(task.id, 'blocked')
|
||||
await this.cb.emit_event({
|
||||
type: 'task.blocked',
|
||||
task_id: task.id,
|
||||
payload: { task_id: task.id, reason: decision.reason, blocker_kind: 'retry_exhausted', evidence_refs: [], suggested_next_step: decision.escalate_to || 'manual_review' },
|
||||
})
|
||||
break
|
||||
case 'cancel':
|
||||
this.graph.update_status(task.id, 'cancelled')
|
||||
this.retry_attempts.delete(task.id)
|
||||
break
|
||||
case 'debug':
|
||||
this.graph.update_status(task.id, 'pending')
|
||||
this.retry_attempts.set(task.id, attempts + 1)
|
||||
await this.cb.emit_event({
|
||||
type: 'task.debug_requested',
|
||||
task_id: task.id,
|
||||
payload: { task_id: task.id, reason: decision.reason },
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
this.state = 'PLANNING_WAVE'
|
||||
break
|
||||
}
|
||||
|
||||
case 'BLOCKED':
|
||||
case 'CANCELLED':
|
||||
case 'COMPLETED':
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
private terminal_state_from_counts(counts: Record<string, number>): SchedulerState {
|
||||
if ((counts.failed || 0) > 0) return 'BLOCKED'
|
||||
if ((counts.blocked || 0) > 0) return 'BLOCKED'
|
||||
if ((counts.cancelled || 0) > 0) return 'CANCELLED'
|
||||
return 'COMPLETED'
|
||||
}
|
||||
|
||||
private get_failure_signature(task: { id: TaskID; type?: string; description?: string }): string {
|
||||
const error = this.failed_task_errors?.get(task.id) || ''
|
||||
return `${task.type || 'unknown'}:${error.slice(0, 50)}`
|
||||
}
|
||||
|
||||
record_task_failure(task_id: TaskID, error_message: string): void {
|
||||
if (!this.failed_task_errors) this.failed_task_errors = new Map()
|
||||
this.failed_task_errors.set(task_id, error_message)
|
||||
}
|
||||
|
||||
add_dependency(task_id: string, depends_on: string): void {
|
||||
this.graph.add_dependency(task_id, depends_on, 'hard')
|
||||
}
|
||||
|
||||
load_graph(tasks: Array<{ id: string; depends_on?: string[] }>): void {
|
||||
for (const task of tasks) {
|
||||
this.graph.add_task_by_id(task.id)
|
||||
if (task.depends_on) {
|
||||
for (const dep of task.depends_on) {
|
||||
this.graph.add_dependency(task.id, dep, 'hard')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async cancel_task(task_id: string): Promise<boolean> {
|
||||
await this.cb.emit_event({
|
||||
type: 'task.cancelled',
|
||||
task_id: task_id,
|
||||
payload: { task_id: task_id, reason: 'user_requested' },
|
||||
})
|
||||
return this.graph.remove_task(task_id)
|
||||
}
|
||||
}
|
||||
473
packages/scheduler/src/TaskGraph.ts
Executable file
473
packages/scheduler/src/TaskGraph.ts
Executable file
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* TaskGraph - Dependency graph for task scheduling
|
||||
*
|
||||
* Implements DD §7.2.
|
||||
* get_runnable_tasks (hard deps done, conflicts blocked), dependents_of, validate_refs.
|
||||
* FR-007.5: ADR 级联失效 — invalidate_by_adr, tasks_by_adr, _find_downstream, _create_rollback_snapshot.
|
||||
*
|
||||
* @module packages/runtime/src/scheduler/TaskGraph
|
||||
*/
|
||||
|
||||
import type { TaskID, SessionID } from '@aircoding/contracts'
|
||||
import { execSync } from 'child_process'
|
||||
|
||||
export type DependencyType = 'hard' | 'soft' | 'conflict'
|
||||
|
||||
export interface TaskNode {
|
||||
id: TaskID
|
||||
type?: string
|
||||
status: string
|
||||
dependencies: Array<{ task_id: TaskID; type: DependencyType }>
|
||||
title?: string
|
||||
description?: string
|
||||
acceptance_criteria?: string[]
|
||||
task_spec?: Record<string, unknown>
|
||||
/** FR-007.5: ADR references this task depends on (e.g. ["ADR-0005", "ADR-0012"]) */
|
||||
adr_refs?: string[]
|
||||
}
|
||||
|
||||
export interface CascadeReport {
|
||||
invalidated_completed: number
|
||||
terminated_in_progress: number
|
||||
cancelled_pending: number
|
||||
cascaded_downstream: number
|
||||
rollback_ref?: string
|
||||
}
|
||||
|
||||
export interface GraphValidation {
|
||||
valid: boolean
|
||||
errors: Array<{ task_id: TaskID; message: string }>
|
||||
cycles: TaskID[][]
|
||||
}
|
||||
|
||||
export class TaskGraph {
|
||||
private tasks: Map<TaskID, TaskNode> = new Map()
|
||||
/** FR-007.5: 冻结调度派发,阻止新任务派发直到重规划完成 */
|
||||
dispatch_frozen: boolean = false
|
||||
|
||||
/**
|
||||
* Add a task to the graph.
|
||||
*/
|
||||
add_task(task: TaskNode): void {
|
||||
this.tasks.set(task.id, { ...task })
|
||||
}
|
||||
|
||||
/** Update task status in the graph. */
|
||||
update_status(id: TaskID, status: string): void {
|
||||
const task = this.tasks.get(id)
|
||||
if (task) task.status = status
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Soft deps affect priority (weight) but don't block dispatch.
|
||||
*/
|
||||
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')
|
||||
const soft_deps = task.dependencies.filter(d => d.type === 'soft')
|
||||
|
||||
// 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
|
||||
|
||||
// FR-007: Calculate priority weight from soft deps
|
||||
// Tasks with more completed soft deps get higher priority
|
||||
const soft_completed = soft_deps.filter(d => {
|
||||
const dep_task = this.tasks.get(d.task_id)
|
||||
return dep_task && dep_task.status === 'completed'
|
||||
}).length
|
||||
const soft_weight = soft_completed / Math.max(soft_deps.length, 1)
|
||||
|
||||
// Attach computed priority for WavePlanner ordering
|
||||
;(task as any)._soft_dep_weight = soft_weight
|
||||
runnable.push(task)
|
||||
}
|
||||
|
||||
// Sort by soft dependency completion rate (higher = more ready)
|
||||
runnable.sort((a, b) => ((b as any)._soft_dep_weight || 0) - ((a as any)._soft_dep_weight || 0))
|
||||
|
||||
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 tasks filtered by status.
|
||||
*/
|
||||
get_tasks_by_status(status: string): TaskNode[] {
|
||||
return this.get_all().filter(t => t.status === status)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a PlanDelta — incremental graph update from ArchitectureDesigner replanning.
|
||||
* Per V2 §3.2.11: removed tasks only dropped if status is still 'pending';
|
||||
* added/modified tasks merged; edge changes applied additively/removally.
|
||||
* Running/completed tasks are never touched by delta.
|
||||
*/
|
||||
apply_delta(delta: {
|
||||
removed_tasks: string[]
|
||||
added_tasks: Array<{ id: string; type?: string; title?: string; description?: string; dependencies?: Array<{ depends_on_task_id: string; dependency_type: DependencyType }> }>
|
||||
modified_tasks: Array<{ id: string; title?: string; description?: string; dependencies?: Array<{ depends_on_task_id: string; dependency_type: DependencyType }> }>
|
||||
edge_changes: Array<{ task_id: string; depends_on_task_id: string; dependency_type: DependencyType; action: 'add' | 'remove' }>
|
||||
reason: string
|
||||
}): { removed: number; added: number; modified: number; skipped: string[] } {
|
||||
let removed = 0; let added = 0; let modified = 0
|
||||
const skipped: string[] = []
|
||||
|
||||
// 1. Remove tasks — only if still pending
|
||||
for (const id of delta.removed_tasks) {
|
||||
const task = this.tasks.get(id)
|
||||
if (!task) continue
|
||||
if (task.status !== 'pending') {
|
||||
skipped.push(`${id}: status is ${task.status}, not removed`)
|
||||
continue
|
||||
}
|
||||
this.tasks.delete(id)
|
||||
removed++
|
||||
}
|
||||
|
||||
// 2. Add new tasks
|
||||
for (const t of delta.added_tasks) {
|
||||
if (this.tasks.has(t.id)) {
|
||||
skipped.push(`${t.id}: already exists`)
|
||||
continue
|
||||
}
|
||||
this.tasks.set(t.id, {
|
||||
id: t.id,
|
||||
type: t.type,
|
||||
status: 'pending',
|
||||
title: t.title,
|
||||
description: t.description,
|
||||
dependencies: (t.dependencies || []).map(d => ({ task_id: d.depends_on_task_id, type: d.dependency_type })),
|
||||
})
|
||||
added++
|
||||
}
|
||||
|
||||
// 3. Modify existing tasks — title/description/deps only for pending tasks
|
||||
for (const t of delta.modified_tasks) {
|
||||
const task = this.tasks.get(t.id)
|
||||
if (!task) { skipped.push(`${t.id}: not found`); continue }
|
||||
if (task.status !== 'pending') {
|
||||
skipped.push(`${t.id}: status is ${task.status}, skipped modify`)
|
||||
continue
|
||||
}
|
||||
if (t.title !== undefined) task.title = t.title
|
||||
if (t.description !== undefined) task.description = t.description
|
||||
if (t.dependencies !== undefined) {
|
||||
task.dependencies = t.dependencies.map(d => ({ task_id: d.depends_on_task_id, type: d.dependency_type }))
|
||||
}
|
||||
modified++
|
||||
}
|
||||
|
||||
// 4. Edge changes — add or remove individual dependencies
|
||||
for (const e of delta.edge_changes) {
|
||||
const task = this.tasks.get(e.task_id)
|
||||
if (!task) { skipped.push(`edge: ${e.task_id} not found`); continue }
|
||||
if (e.action === 'add') {
|
||||
if (!task.dependencies.some(d => d.task_id === e.depends_on_task_id)) {
|
||||
task.dependencies.push({ task_id: e.depends_on_task_id, type: e.dependency_type })
|
||||
}
|
||||
} else {
|
||||
task.dependencies = task.dependencies.filter(d => d.task_id !== e.depends_on_task_id)
|
||||
}
|
||||
}
|
||||
|
||||
return { removed, added, modified, skipped }
|
||||
}
|
||||
|
||||
/**
|
||||
* FR-007.5: Find all tasks that reference a given ADR.
|
||||
*/
|
||||
tasks_by_adr(adr_id: string): TaskNode[] {
|
||||
const result: TaskNode[] = []
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.adr_refs && task.adr_refs.includes(adr_id)) {
|
||||
result.push(task)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* FR-007.5: Find all downstream tasks (transitive dependents) of the given set.
|
||||
*/
|
||||
private _find_downstream(seed: TaskNode[]): TaskNode[] {
|
||||
const seed_ids = new Set(seed.map(t => t.id))
|
||||
const result: TaskNode[] = []
|
||||
const visited = new Set<string>()
|
||||
|
||||
const walk = (task_id: string) => {
|
||||
if (visited.has(task_id)) return
|
||||
visited.add(task_id)
|
||||
for (const dep of this.tasks.values()) {
|
||||
if (dep.dependencies.some(d => d.task_id === task_id)) {
|
||||
if (!seed_ids.has(dep.id)) {
|
||||
result.push(dep)
|
||||
}
|
||||
walk(dep.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const t of seed) walk(t.id)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* FR-007.5: ADR 变更时级联失效所有相关任务。
|
||||
* V2 §3.2.11b: 完整的 6 步失效流程。
|
||||
*/
|
||||
invalidate_by_adr(adr_id: string, delta: {
|
||||
removed_tasks: string[]
|
||||
added_tasks: Array<{ id: string; type?: string; title?: string; description?: string; dependencies?: Array<{ depends_on_task_id: string; dependency_type: DependencyType }> }>
|
||||
modified_tasks: Array<{ id: string; title?: string; description?: string; dependencies?: Array<{ depends_on_task_id: string; dependency_type: DependencyType }> }>
|
||||
edge_changes: Array<{ task_id: string; depends_on_task_id: string; dependency_type: DependencyType; action: 'add' | 'remove' }>
|
||||
reason: string
|
||||
rollback_ref?: string
|
||||
}, project_root: string): CascadeReport {
|
||||
const affected = this.tasks_by_adr(adr_id)
|
||||
const completed = affected.filter(t => t.status === 'completed')
|
||||
const in_progress = affected.filter(t => t.status === 'running')
|
||||
const pending = affected.filter(t => t.status === 'pending')
|
||||
|
||||
// 1. 冻结调度
|
||||
this.dispatch_frozen = true
|
||||
|
||||
// 2. 已完成 → invalidated(保留证据)
|
||||
for (const t of completed) {
|
||||
t.status = 'invalidated'
|
||||
delta.removed_tasks.push(t.id)
|
||||
}
|
||||
|
||||
// 3. 运行中 → cancelled(Scheduler 侧 terminate Worker)
|
||||
for (const t of in_progress) {
|
||||
t.status = 'cancelled'
|
||||
delta.removed_tasks.push(t.id)
|
||||
}
|
||||
|
||||
// 4. 待处理 → cancelled
|
||||
for (const t of pending) {
|
||||
t.status = 'cancelled'
|
||||
delta.removed_tasks.push(t.id)
|
||||
}
|
||||
|
||||
// 5. 级联失效下游
|
||||
const downstream = this._find_downstream([...completed, ...in_progress])
|
||||
for (const t of downstream) {
|
||||
if (t.status === 'pending') {
|
||||
t.status = 'cancelled'
|
||||
delta.removed_tasks.push(t.id)
|
||||
} else if (t.status === 'running') {
|
||||
t.status = 'cancelled'
|
||||
delta.removed_tasks.push(t.id)
|
||||
}
|
||||
// completed downstream tasks are NOT auto-invalidated — they need separate review
|
||||
}
|
||||
|
||||
// 6. 创建 git 回滚快照
|
||||
delta.rollback_ref = this._create_rollback_snapshot(adr_id, completed, project_root)
|
||||
|
||||
return {
|
||||
invalidated_completed: completed.length,
|
||||
terminated_in_progress: in_progress.length,
|
||||
cancelled_pending: pending.length,
|
||||
cascaded_downstream: downstream.filter(t => t.status === 'cancelled').length,
|
||||
rollback_ref: delta.rollback_ref,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FR-007.5: 创建 git 回滚快照。
|
||||
* git commit 所有未提交变更 + tag 标记,支持后续 git revert。
|
||||
*/
|
||||
private _create_rollback_snapshot(adr_id: string, completed_tasks: TaskNode[], project_root: string): string | undefined {
|
||||
if (completed_tasks.length === 0) return undefined
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[-:.]/g, '').slice(0, 15)
|
||||
const ref = `aircoding/rollback-${adr_id}-${timestamp}`
|
||||
|
||||
try {
|
||||
// Stage all changes and commit as a snapshot
|
||||
execSync('git add -A', { cwd: project_root, stdio: 'pipe', timeout: 30000 })
|
||||
execSync(`git commit -m "AirCoding rollback snapshot: ${adr_id} invalidated (${completed_tasks.length} tasks)" --allow-empty`, { cwd: project_root, stdio: 'pipe', timeout: 30000 })
|
||||
execSync(`git tag "${ref}"`, { cwd: project_root, stdio: 'pipe', timeout: 10000 })
|
||||
return ref
|
||||
} catch (e: any) {
|
||||
// If snapshot creation fails (e.g. no changes to commit), still return the ref for documentation
|
||||
const msg = e.stderr ? (typeof e.stderr === 'string' ? e.stderr : e.stderr.toString()).slice(0, 200) : e.message
|
||||
if (msg.includes('nothing to commit') || msg.includes('nothing added')) {
|
||||
return `${ref}-empty`
|
||||
}
|
||||
console.warn('Failed to create rollback snapshot:', msg)
|
||||
return `${ref}-failed`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FR-007.5: git revert 基于回滚快照的旧方案代码。
|
||||
* 用户确认后才调用,不可逆操作。
|
||||
*/
|
||||
revert_to_snapshot(rollback_ref: string, project_root: string): { ok: boolean; message: string } {
|
||||
if (!rollback_ref || rollback_ref.endsWith('-empty') || rollback_ref.endsWith('-failed')) {
|
||||
return { ok: false, message: `No valid rollback snapshot: ${rollback_ref}` }
|
||||
}
|
||||
|
||||
try {
|
||||
// Find the commit tagged with this ref
|
||||
const commit = execSync(`git rev-parse "${rollback_ref}^{}"`, { cwd: project_root, encoding: 'utf-8', stdio: 'pipe', timeout: 10000 }).trim()
|
||||
if (!commit) {
|
||||
return { ok: false, message: `Rollback ref not found: ${rollback_ref}` }
|
||||
}
|
||||
|
||||
// Revert the changes introduced by the snapshot
|
||||
execSync(`git revert --no-commit ${commit}..HEAD`, { cwd: project_root, stdio: 'pipe', timeout: 60000 })
|
||||
return { ok: true, message: `Reverted to ${rollback_ref}. Review changes before committing.` }
|
||||
} catch (e: any) {
|
||||
// If revert conflicts, abort and report
|
||||
try { execSync('git revert --abort', { cwd: project_root, stdio: 'pipe' }) } catch {}
|
||||
const msg = e.stderr ? (typeof e.stderr === 'string' ? e.stderr : e.stderr.toString()).slice(0, 300) : e.message
|
||||
return { ok: false, message: `Revert failed (conflicts likely): ${msg}` }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a task from the graph.
|
||||
*/
|
||||
remove_task(task_id: TaskID): boolean {
|
||||
return this.tasks.delete(task_id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add task by ID only (convenience for load_graph).
|
||||
*/
|
||||
add_task_by_id(task_id: TaskID): void {
|
||||
this.tasks.set(task_id, {
|
||||
id: task_id,
|
||||
status: 'pending',
|
||||
dependencies: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
108
packages/scheduler/src/WavePlanner.ts
Executable file
108
packages/scheduler/src/WavePlanner.ts
Executable file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 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
|
||||
const areas: Map<string, string[]> = new Map()
|
||||
|
||||
for (const task of tasks) {
|
||||
// Determine write area from task scope or metadata
|
||||
const scope = (task as any).scope || {}
|
||||
const area = (scope.write_area as string) || (task as any).write_area || 'default'
|
||||
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'
|
||||
}
|
||||
}
|
||||
362
packages/scheduler/src/WorkspaceManager.ts
Executable file
362
packages/scheduler/src/WorkspaceManager.ts
Executable file
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* 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, cpSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs'
|
||||
import { join, relative, dirname } from 'path'
|
||||
import { execSync } from 'child_process'
|
||||
|
||||
export type WorkspaceStrategy = 'main' | 'worktree' | 'isolated_copy'
|
||||
|
||||
export interface Workspace {
|
||||
id: string
|
||||
path: string
|
||||
strategy: WorkspaceStrategy
|
||||
state: 'active' | 'merged' | 'conflicted' | 'abandoned' | 'cleaned'
|
||||
created_at: string
|
||||
merged_at?: string
|
||||
task_id?: string
|
||||
parent_path?: string
|
||||
}
|
||||
|
||||
interface MergeConflict {
|
||||
file: string
|
||||
content_workspace?: string
|
||||
content_parent?: 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,
|
||||
parent_path: this.project_root
|
||||
}
|
||||
|
||||
// For isolated_copy, initialize as a copy of project root
|
||||
if (strategy === 'isolated_copy') {
|
||||
try {
|
||||
this.initialize_from_parent(ws.path, this.project_root)
|
||||
} catch (e) {
|
||||
console.warn('Failed to initialize workspace from parent:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// For worktree, init git worktree
|
||||
if (strategy === 'worktree') {
|
||||
try {
|
||||
this.initialize_git_worktree(ws.path, workspace_id)
|
||||
} catch (e) {
|
||||
console.warn('Failed to initialize git worktree:', e)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize workspace as copy of parent project
|
||||
*/
|
||||
private initialize_from_parent(workspace_path: string, parent_path: string): void {
|
||||
const ignored = new Set(['.air', '.git', 'node_modules', 'build', 'dist', '.claude'])
|
||||
const copy_dir = (src: string, dest: string) => {
|
||||
if (!existsSync(src)) return
|
||||
mkdirSync(dest, { recursive: true })
|
||||
for (const entry of readdirSync(src)) {
|
||||
if (ignored.has(entry)) continue
|
||||
const src_path = join(src, entry)
|
||||
const dest_path = join(dest, entry)
|
||||
const stat = statSync(src_path)
|
||||
if (stat.isDirectory()) {
|
||||
copy_dir(src_path, dest_path)
|
||||
} else {
|
||||
cpSync(src_path, dest_path)
|
||||
}
|
||||
}
|
||||
}
|
||||
copy_dir(parent_path, workspace_path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize workspace as git worktree
|
||||
*/
|
||||
private initialize_git_worktree(workspace_path: string, worktree_name: string): void {
|
||||
try {
|
||||
execSync(`git worktree add "${workspace_path}" -B air-coding/${worktree_name}`, {
|
||||
cwd: this.project_root,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
} catch (e) {
|
||||
// Fall back to copy if worktree fails
|
||||
this.initialize_from_parent(workspace_path, this.project_root)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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; conflicts?: MergeConflict[] }> {
|
||||
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 {
|
||||
// Strategy-specific merge
|
||||
if (ws.strategy === 'main') {
|
||||
// No merge needed
|
||||
ws.state = 'merged'
|
||||
ws.merged_at = new Date().toISOString()
|
||||
return { ok: true, conflict: false, message: 'Main strategy - no merge needed' }
|
||||
}
|
||||
|
||||
if (ws.strategy === 'worktree') {
|
||||
return this.merge_git_worktree(ws)
|
||||
}
|
||||
|
||||
// isolated_copy: file-level merge
|
||||
return this.merge_file_copy(ws)
|
||||
} catch (error) {
|
||||
return { ok: false, conflict: true, message: error instanceof Error ? error.message : 'Merge failed' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge git worktree back to main
|
||||
*/
|
||||
private async merge_git_worktree(ws: Workspace): Promise<{ ok: boolean; conflict: boolean; message: string; conflicts?: MergeConflict[] }> {
|
||||
try {
|
||||
// Try git merge
|
||||
execSync(`git merge --no-commit air-coding/${ws.id.replace('ws_', '')}`, {
|
||||
cwd: this.project_root,
|
||||
stdio: 'pipe'
|
||||
})
|
||||
// Check for conflicts
|
||||
const conflict_files = this.get_conflict_files()
|
||||
if (conflict_files.length > 0) {
|
||||
// Abort merge, mark as conflicted
|
||||
execSync('git merge --abort', { cwd: this.project_root, stdio: 'ignore' })
|
||||
ws.state = 'conflicted'
|
||||
const conflicts: MergeConflict[] = conflict_files.map(f => ({
|
||||
file: f,
|
||||
content_workspace: this.read_workspace_file(ws, f),
|
||||
content_parent: this.read_parent_file(f)
|
||||
}))
|
||||
return { ok: false, conflict: true, message: `${conflict_files.length} merge conflicts`, conflicts }
|
||||
}
|
||||
// Commit the merge
|
||||
execSync('git commit -m "Merge workspace changes"', { cwd: this.project_root, stdio: 'ignore' })
|
||||
ws.state = 'merged'
|
||||
ws.merged_at = new Date().toISOString()
|
||||
// Cleanup worktree
|
||||
execSync(`git worktree remove "${ws.path}" --force`, { cwd: this.project_root, stdio: 'ignore' })
|
||||
return { ok: true, conflict: false, message: 'Merged via git' }
|
||||
} catch (e) {
|
||||
// Fall through to file-level merge
|
||||
return this.merge_file_copy(ws)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of files with merge conflicts
|
||||
*/
|
||||
private get_conflict_files(): string[] {
|
||||
try {
|
||||
const output = execSync('git diff --name-only --diff-filter=U', {
|
||||
cwd: this.project_root,
|
||||
encoding: 'utf-8'
|
||||
})
|
||||
return output.split('\n').filter(f => f.trim())
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file content from workspace
|
||||
*/
|
||||
private read_workspace_file(ws: Workspace, relative_path: string): string | undefined {
|
||||
try {
|
||||
const full_path = join(ws.path, relative_path)
|
||||
return existsSync(full_path) ? readFileSync(full_path, 'utf-8') : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file content from parent
|
||||
*/
|
||||
private read_parent_file(relative_path: string): string | undefined {
|
||||
try {
|
||||
const full_path = join(this.project_root, relative_path)
|
||||
return existsSync(full_path) ? readFileSync(full_path, 'utf-8') : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge via file copy-back (for isolated_copy strategy or git fallback)
|
||||
*/
|
||||
private async merge_file_copy(ws: Workspace): Promise<{ ok: boolean; conflict: boolean; message: string; conflicts?: MergeConflict[] }> {
|
||||
const ignored = new Set(['.air', '.git', 'node_modules', 'build', 'dist', '.claude'])
|
||||
const conflicts: MergeConflict[] = []
|
||||
|
||||
const merge_dir = (ws_path: string, parent_path: string, rel_path: string = '') => {
|
||||
if (!existsSync(ws_path)) return
|
||||
|
||||
for (const entry of readdirSync(ws_path)) {
|
||||
if (ignored.has(entry)) continue
|
||||
|
||||
const ws_file = join(ws_path, entry)
|
||||
const parent_file = join(parent_path, entry)
|
||||
const rel_file = rel_path ? `${rel_path}/${entry}` : entry
|
||||
|
||||
const stat = statSync(ws_file)
|
||||
if (stat.isDirectory()) {
|
||||
merge_dir(ws_file, parent_file, rel_file)
|
||||
} else {
|
||||
// Check if file was modified in workspace
|
||||
const ws_content = readFileSync(ws_file, 'utf-8')
|
||||
const parent_exists = existsSync(parent_file)
|
||||
const parent_content = parent_exists ? readFileSync(parent_file, 'utf-8') : ''
|
||||
|
||||
if (!parent_exists) {
|
||||
// New file - copy to parent
|
||||
mkdirSync(dirname(parent_file), { recursive: true })
|
||||
cpSync(ws_file, parent_file)
|
||||
} else if (ws_content !== parent_content) {
|
||||
// Modified - detect conflict
|
||||
if (this.file_has_conflict(ws_file, parent_file)) {
|
||||
conflicts.push({
|
||||
file: rel_file,
|
||||
content_workspace: ws_content,
|
||||
content_parent: parent_content
|
||||
})
|
||||
} else {
|
||||
// No conflict - take workspace version
|
||||
cpSync(ws_file, parent_file)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
merge_dir(ws.path, ws.parent_path || this.project_root)
|
||||
|
||||
if (conflicts.length > 0) {
|
||||
ws.state = 'conflicted'
|
||||
return { ok: false, conflict: true, message: `${conflicts.length} file conflicts`, conflicts }
|
||||
}
|
||||
|
||||
ws.state = 'merged'
|
||||
ws.merged_at = new Date().toISOString()
|
||||
return { ok: true, conflict: false, message: 'Merged via file copy-back' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two files have conflicts (different content)
|
||||
*/
|
||||
private file_has_conflict(workspace_file: string, parent_file: string): boolean {
|
||||
const ws_content = readFileSync(workspace_file, 'utf-8')
|
||||
const parent_content = readFileSync(parent_file, 'utf-8')
|
||||
return ws_content !== parent_content
|
||||
}
|
||||
|
||||
/**
|
||||
* 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' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active workspaces.
|
||||
*/
|
||||
get_active(): Workspace[] {
|
||||
return Array.from(this.workspaces.values()).filter(ws => ws.state === 'active')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
}
|
||||
8
packages/scheduler/src/index.ts
Executable file
8
packages/scheduler/src/index.ts
Executable file
@@ -0,0 +1,8 @@
|
||||
export { TaskGraph } from './TaskGraph.js'
|
||||
export type { CascadeReport } from './TaskGraph.js'
|
||||
export { Scheduler } from './Scheduler.js'
|
||||
export type { SchedulerState, SchedulerContext } from './Scheduler.js'
|
||||
export { WavePlanner } from './WavePlanner.js'
|
||||
export { RetryPlanner } from './RetryPlanner.js'
|
||||
export { WorkspaceManager } from './WorkspaceManager.js'
|
||||
export { AgentMonitor } from './AgentMonitor.js'
|
||||
14
packages/scheduler/tsconfig.json
Executable file
14
packages/scheduler/tsconfig.json
Executable file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user