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:
AirCoding
2026-06-12 17:12:29 +08:00
parent 8f55c962bb
commit ae44be31d5
364 changed files with 46779 additions and 2812 deletions

18
packages/scheduler/package.json Executable file
View 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"
}
}

View 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)
}
}

View 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
}
}
}

View 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)
}
}

View 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. 运行中 → cancelledScheduler 侧 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: [],
})
}
}

View 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'
}
}

View 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
}
}
}

View 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'

View 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"]
}