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:
@@ -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`)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface WorkerConfig {
|
||||
project_root: string
|
||||
timeout_ms?: number
|
||||
env?: Record<string, string>
|
||||
task_type?: string // DD §8.3: execute/review/debug/compact/mine_experience
|
||||
task_spec?: Record<string, unknown> // 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'
|
||||
|
||||
Reference in New Issue
Block a user