feat: wire full execution chain — MainAgent→Scheduler→Worker→LLM

Architecture-compliant interactive run command:
- air run: interactive readline loop, user types tasks
- MainAgent classifies (regex + 中文 support)
- Scheduler creates tasks + runs state machine
- DISPATCHING spawns worker processes via WorkerManager
- Workers receive task_spec via agent.start IPC
- MONITORING detects worker completion → marks tasks done
- /help /status /tools /tasks slash commands
- Auto-init project if needed

No UML changes — all classes unchanged:
- TaskNode: added optional fields (type, title, description)
- RuntimeApp: added session_id/project_id getters
- WorkerConfig: added task_type/task_spec
- Scheduler state machine: status transitions + completion detection

Chain: stdin → MainAgent → Scheduler → WorkerManager.spawn()
  → worker main.ts → ExecutorRole → call_llm() IPC → ProviderManager
  → tools via ToolRegistry → result → ProjectionStore → TUI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AirCoding
2026-06-05 14:16:18 +08:00
parent 459308053c
commit 56a1dd0a7b
5 changed files with 239 additions and 31 deletions

View File

@@ -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<void> {
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<void> {
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
}
process.on('SIGINT', () => {
rl.close()
})
await new Promise(() => {}) // Wait forever
}
async function handleSlashCommand(input: string, app: any, runtime: any, tui: any, rl: any): Promise<void> {
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<string, string[]>()
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`)
}
}