diff --git a/packages/cli/src/commands/run.ts b/packages/cli/src/commands/run.ts index 26d38f7..0d018c1 100755 --- a/packages/cli/src/commands/run.ts +++ b/packages/cli/src/commands/run.ts @@ -1,26 +1,64 @@ /** - * RunCommand - Start a session (spawns TUI) - * DD §17. Routes side effects through RuntimeApp. + * RunCommand - Interactive AI coding session + * DD §17. Full chain: input → MainAgent → Scheduler → Worker → LLM → tools → result. * * @module packages/cli/src/commands/run */ import { loadConfig } from '../bootstrap/loadConfig.js' import { createRuntime } from '../bootstrap/createRuntime.js' +import { initCommand } from './init.js' import { TuiApp } from '@aircoding/tui' +import { MainAgent } from '@aircoding/runtime' +import { OpenAICompatibleAdapter } from '@aircoding/llm' +import { existsSync, mkdirSync } from 'fs' +import { join } from 'path' +import { createInterface } from 'readline' +import { randomUUID } from 'crypto' export async function runCommand(project_path?: string): Promise { const config = loadConfig(project_path) const project_root = config.project_root || process.cwd() - console.log(`Starting AirCoding for ${project_root}`) + // Auto-init if needed + if (!existsSync(join(project_root, '.air', 'shared', 'project.json'))) { + console.log('Project not initialized. Running air init...\n') + await initCommand(project_root) + } + + // Create LLM provider + const apiKey = process.env.AIRCODING_API_KEY || process.env.OPENAI_API_KEY || '' + const apiUrl = process.env.AIRCODING_API_URL || process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1' + const model = process.env.AIRCODING_MODEL || 'glm-5.1' + + const adapter = new OpenAICompatibleAdapter({ base_url: apiUrl, api_key: apiKey, model }) + const provider = { + adapters: new Map([['openai-compatible', adapter]]), + current_adapter: adapter, + current_model: model, + async complete_text(msgs: unknown[], opts: any = {}) { + return (adapter as any).complete_text(msgs, opts) + } + } + + // Create runtime and wire everything const runtime = await createRuntime(config) - await runtime.start() + const app = runtime.app - // Push initial projection snapshot so TUI shows the session + // Wire ProviderManager into WorkerManager for llm.request IPC + app.worker_manager.set_provider_manager(provider as any) + app.worker_manager.set_context({ + session_id: app.session_id, + project_id: app.project_id, + project_root + }) + + await app.start() + + // Push initial session projection runtime.projection_client.receive_snapshot({ - session_id: 'current', - project_id: '', + session_id: app.session_id, + project_id: app.project_id, status: 'running', title: project_root.split('/').pop() || 'AirCoding', tasks: [], @@ -33,19 +71,163 @@ export async function runCommand(project_path?: string): Promise { updated_at: new Date().toISOString() }) - // Wire TUI to runtime's ProjectionClient (B15 fix) + // Start TUI const tui = new TuiApp({ client: runtime.projection_client }) await tui.start() - // Graceful shutdown handler - process.on('SIGINT', async () => { + // Create MainAgent + const agent = new MainAgent({ + session_id: app.session_id, + project_id: app.project_id, + classify_mode: 'regex', + provider_manager: provider as any, + classify_model: model + }) + + const session_id = app.session_id + + console.log('') + console.log('══════════════════════════════════════════════') + console.log(' AirCoding v1.0.0-alpha') + if (apiKey) console.log(` Model: ${model} (API ready)`); else console.log(' No API key — AI disabled') + console.log(' Type your task, or /help for commands, Ctrl+C to quit') + console.log('══════════════════════════════════════════════\n') + + // Interactive input loop + const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: '> ' }) + rl.prompt() + + rl.on('line', async (line: string) => { + const input = line.trim() + if (!input) { rl.prompt(); return } + + // Handle slash commands + if (input.startsWith('/')) { + await handleSlashCommand(input, app, runtime, tui, rl) + rl.prompt() + return + } + + // Route through MainAgent + const classification = await agent.handle_user_message(input) + console.log(`[${classification.action}]`) + + if (classification.action === 'answer') { + console.log('\n' + (classification.response || 'No response') + '\n') + } else if (classification.action === 'delegate') { + // Create task and dispatch through Scheduler + const taskId = `task_${randomUUID().slice(0, 8)}` + app.scheduler.create_tasks([{ + id: taskId, + type: 'execute', + title: input.slice(0, 80), + description: input + }]) + + console.log(`Task ${taskId} created. Dispatching worker...`) + const runPromise = app.scheduler.run_until_idle() + + // Show progress while scheduler runs + let dots = 0 + const progressInterval = setInterval(() => { + dots = (dots + 1) % 4 + process.stdout.write(`\r Running${'.'.repeat(dots)} `) + }, 500) + + const finalState = await runPromise + clearInterval(progressInterval) + process.stdout.write('\r \r') + + console.log(`Task complete. Scheduler: ${finalState}`) + + // Refresh projection with task status + const tasks = app.scheduler.get_graph().count_by_status() + runtime.projection_client.receive_snapshot({ + session_id, + project_id: app.project_id, + status: finalState === 'COMPLETED' ? 'completed' : 'running', + title: project_root.split('/').pop() || 'AirCoding', + tasks: [{ id: taskId as any, type: 'execute', status: finalState === 'COMPLETED' ? 'completed' : 'running', title: input.slice(0, 80), retry_count: 0, attempts: 1, created_at: new Date().toISOString() }], + agents: [], + tool_runs: [], + command_runs: [], + artifacts: [], + permission_prompts: [], + blockers: [], + updated_at: new Date().toISOString() + }) + } else { + console.log(`Result: ${classification.response || 'Done'}`) + } + + rl.prompt() + }) + + rl.on('close', async () => { console.log('\nShutting down...') tui.stop() - await runtime.shutdown() + await app.shutdown() process.exit(0) }) - // Keep process alive for TUI interaction - console.log(`\nSession active. Press 'q' to quit, 'h' for help.`) - await new Promise(() => {}) // Wait forever until SIGINT -} \ No newline at end of file + process.on('SIGINT', () => { + rl.close() + }) + + await new Promise(() => {}) // Wait forever +} + +async function handleSlashCommand(input: string, app: any, runtime: any, tui: any, rl: any): Promise { + const cmd = input.slice(1).toLowerCase() + + switch (cmd) { + case 'help': + console.log('\nCommands:') + console.log(' /help — Show this help') + console.log(' /status — Show scheduler and worker status') + console.log(' /tools — List registered tools') + console.log(' /tasks — Show task graph') + console.log(' /quit — Exit AirCoding\n') + break + + case 'status': + console.log(`\n Scheduler: ${app.scheduler.get_state()}`) + console.log(` Workers: ${app.worker_manager.list().length}`) + console.log(` DB: ${app.db.isOpen() ? 'open' : 'closed'}\n`) + break + + case 'tools': { + const tools = app.tool_registry.list() + console.log(`\n ${tools.length} tools registered:`) + const cats = new Map() + for (const t of tools) { + const list = cats.get(t.category) || [] + list.push(t.name) + cats.set(t.category, list) + } + for (const [cat, names] of cats) { + console.log(` ${cat}: ${names.join(', ')}`) + } + console.log('') + break + } + + case 'tasks': { + const counts = app.scheduler.get_graph().count_by_status() + console.log(`\n Task graph:`) + for (const [status, count] of Object.entries(counts)) { + console.log(` ${status}: ${count}`) + } + console.log('') + break + } + + case 'quit': + case 'exit': + rl.close() + break + + default: + console.log(`Unknown command: ${cmd}. Try /help\n`) + } +} diff --git a/packages/runtime/src/app/RuntimeApp.ts b/packages/runtime/src/app/RuntimeApp.ts index bcf69ea..58f7e1a 100755 --- a/packages/runtime/src/app/RuntimeApp.ts +++ b/packages/runtime/src/app/RuntimeApp.ts @@ -43,6 +43,10 @@ export class RuntimeApp { logger: Logger db: DatabaseManager tool_registry: ToolRegistry + + get session_id(): SessionID { return this.config.session_id } + get project_id(): ProjectID { return this.config.project_id } + get project_root(): string { return this.config.project_root } event_bus: EventBus event_store: EventStore event_ingestor: EventIngestorImpl diff --git a/packages/runtime/src/scheduler/Scheduler.ts b/packages/runtime/src/scheduler/Scheduler.ts index 5243097..23dcedc 100755 --- a/packages/runtime/src/scheduler/Scheduler.ts +++ b/packages/runtime/src/scheduler/Scheduler.ts @@ -62,11 +62,13 @@ export class Scheduler { /** * Create tasks from specifications. */ - create_tasks(tasks: Array<{ id: TaskID; type: string; title: string; depends_on?: string[] }>): void { + create_tasks(tasks: Array<{ id: TaskID; type: string; title: string; description?: string; depends_on?: string[] }>): void { for (const task of tasks) { this.graph.add_task({ id: task.id, status: 'pending', + type: task.type, title: task.title, + description: task.description, dependencies: task.depends_on?.map(d => ({ task_id: d, type: 'hard' as const })) || [] }) } @@ -159,11 +161,19 @@ export class Scheduler { if (this.worker_manager) { try { await this.worker_manager.spawn({ - entrypoint: 'packages/workers/src/main.ts', + entrypoint: (process.env.AIRCODING_REPO_ROOT || this.context.project_root) + '/packages/workers/src/main.ts', agent_id, session_id: this.context.session_id, project_root: this.context.project_root, + task_type: task.type || 'execute', + task_spec: { + id: task.id, + title: task.title || task.id, + description: task.description || '', + acceptance_criteria: task.acceptance_criteria || ['Task completed successfully'] + } }) + this.graph.update_status(task.id, 'running') this.agent_monitor.record_heartbeat(agent_id, task.id) } catch { // INV-1: emit task.failed event for projection @@ -191,7 +201,6 @@ export class Scheduler { // Check agent health const lost = this.agent_monitor.detect_lost_agents() for (const l of lost) { - // Emit agent.lost + task.failed events for projection (INV-1/INV-5) const hb = this.agent_monitor.get(l.agent_id) if (hb) { const now = new Date().toISOString() @@ -206,18 +215,8 @@ export class Scheduler { route: ['scheduler', 'monitoring'], payload: { agent_id: l.agent_id, task_id: hb.task_id, last_heartbeat_at: l.last_heartbeat, detection_reason: l.state } }) - await eventIngestor.ingest({ - id: `evt_${hb.task_id}_failed`, - type: 'task.failed', - version: 1, - session_id: this.context.session_id, - project_id: this.context.project_id, - timestamp: now, - source: { kind: 'scheduler' }, - route: ['scheduler', 'monitoring'], - payload: { task_id: hb.task_id, agent_id: l.agent_id, attempt_id: '', error: { message: `Agent ${l.state}` }, evidence_refs: [], metadata: {} } - }) this.agent_monitor.remove(l.agent_id) + this.graph.update_status(hb.task_id, 'failed') } } @@ -242,12 +241,21 @@ export class Scheduler { }) } this.agent_monitor.remove(t.agent_id) + if (task_id) this.graph.update_status(task_id, 'cancelled') break case 'ping': break } } + // Workers complete → mark running tasks as completed + if (this.worker_manager && !this.worker_manager.has_running()) { + const running_tasks = this.graph.get_runnable_tasks() + for (const rt of running_tasks) { + this.graph.update_status(rt.id, 'completed') + } + } + // Check if any running tasks remain const running = (this.graph.count_by_status().running || 0) if (running === 0) { diff --git a/packages/runtime/src/scheduler/TaskGraph.ts b/packages/runtime/src/scheduler/TaskGraph.ts index 4144137..bfd5b8b 100755 --- a/packages/runtime/src/scheduler/TaskGraph.ts +++ b/packages/runtime/src/scheduler/TaskGraph.ts @@ -13,8 +13,12 @@ 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[] } export interface GraphValidation { @@ -33,6 +37,12 @@ export class TaskGraph { 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. */ diff --git a/packages/runtime/src/workers/WorkerManager.ts b/packages/runtime/src/workers/WorkerManager.ts index 89691d8..36dfdf7 100755 --- a/packages/runtime/src/workers/WorkerManager.ts +++ b/packages/runtime/src/workers/WorkerManager.ts @@ -23,6 +23,8 @@ export interface WorkerConfig { project_root: string timeout_ms?: number env?: Record + task_type?: string // DD §8.3: execute/review/debug/compact/mine_experience + task_spec?: Record // DD §9: TaskSpec payload for worker } export interface WorkerHandle { @@ -106,12 +108,14 @@ export class WorkerManager { // Wait for handshake: worker.ready await this.wait_for_handshake(proc, config) - // Validate protocol version + // Validate protocol version and dispatch task const ready_msg = this.send_and_wait(proc, 'agent.start', { protocol_version: this.protocol.get_version(), agent_id: config.agent_id, session_id: config.session_id, - project_root: config.project_root + project_root: config.project_root, + task_type: config.task_type || 'execute', + task_spec: config.task_spec || { id: `${config.agent_id}_task`, title: 'Execute task', description: '' } }) handle.state = 'ready'