主线 A(事件驱动落库): - 统一 EventStore 模块单例:RuntimeApp 不再 new EventStore,改用 eventStore 并 setRepositories(14 个 domain repo),消除事件流向空 DB 的割裂 - Scheduler.create_tasks 改为 async,真正发出 task.created 事件 - run.ts dispatchTask 加 await - 主线 A 独立复审发现并修复关键假绿:四个 repo(Task/Agent/ToolRun/ TaskAttempt)的 *Update 类型 Omit<'status'> 且 update() 主动丢弃 status, 导致 EventStore.project() 的状态写入全部静默失效,DB 行内容 tasks.status 永远冻结在 pending,UI 显示的 completed 来自内存 graph。已修,DB 现 真实反映 task.status=completed - 补 agent.started/agent.completed/agent.failed 事件发出(之前 agents 表 恒空),修复后 agents 表有正确行+status 主线 B1(结构化工具调用块类型,N1): - 新增 content-block.ts 定义 Anthropic canonical content blocks (TextBlock/ThinkingBlock/ToolUseBlock/ToolResultBlock/CanonicalMessage) - provider.ts ProviderCompletionInput 去掉 unknown 逃生舱: messages: CanonicalMessage[], tools?: ToolDefinitionBlock[], tool_choice?: ToolChoice, system?: string | TextBlock[] 主线 B2(read-before-edit 代码层强制,FR-009): - fs/index.ts 新增 readFileState 机制(移植 claude-code FileEditTool), fs.edit 执行前检查:未读先改报 "File has not been read yet",外部修改 报 "File has been unexpectedly modified" - 修复 fs.edit 参数名不匹配:兼容 old_str/new_str (ExecutorRole) 和 find/replace (UI) 两种命名 - fs_edit 唯一性检查(非 global 模式下 old_str 出现多次报错) 真实验收: - TSC=0 - air run 后 DB:events=5(原 3,+agent.started/completed), tasks.status=completed(原 frozen pending),agents 1 行 status=completed - read-before-edit 行为测试:未读先改 status=error,读后再改 status=ok Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
326 lines
11 KiB
TypeScript
Executable File
326 lines
11 KiB
TypeScript
Executable File
/**
|
|
* 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()
|
|
|
|
// 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)
|
|
const app = runtime.app
|
|
|
|
// 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: app.session_id,
|
|
project_id: app.project_id,
|
|
status: 'running',
|
|
title: project_root.split('/').pop() || 'AirCoding',
|
|
tasks: [],
|
|
agents: [],
|
|
tool_runs: [],
|
|
command_runs: [],
|
|
artifacts: [],
|
|
permission_prompts: [],
|
|
blockers: [],
|
|
updated_at: new Date().toISOString()
|
|
})
|
|
|
|
// Start TUI
|
|
const tui = new TuiApp({ client: runtime.projection_client })
|
|
await tui.start()
|
|
|
|
// Create MainAgent
|
|
const agent = new MainAgent({
|
|
session_id: app.session_id,
|
|
project_id: app.project_id,
|
|
classify_mode: 'regex',
|
|
provider_manager: provider as any,
|
|
context_assembler: app.context_assembler,
|
|
project_root,
|
|
agent_id: 'main-agent' as any,
|
|
classify_model: model
|
|
})
|
|
|
|
const session_id = app.session_id
|
|
|
|
// Track task results for /results command
|
|
const taskResults = new Map<string, { title: string; files: Array<{ path: string; size: number }>; state: string }>()
|
|
let pendingConfirmation: string | undefined
|
|
|
|
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')
|
|
|
|
const dispatchTask = async (input: string) => {
|
|
const taskId = `task_${randomUUID().slice(0, 8)}`
|
|
await 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()
|
|
|
|
let dots = 0
|
|
const progressInterval = setInterval(() => {
|
|
dots = (dots + 1) % 4
|
|
process.stdout.write(`\r Running${'.'.repeat(dots)} `)
|
|
}, 500)
|
|
|
|
let finalState: string
|
|
try {
|
|
finalState = await runPromise
|
|
} finally {
|
|
clearInterval(progressInterval)
|
|
process.stdout.write('\r \r')
|
|
}
|
|
|
|
console.log(`Task complete. Scheduler: ${finalState}`)
|
|
|
|
const workerResult = app.worker_manager.get_result_for_task?.(taskId)
|
|
const resultFiles = Array.isArray(workerResult?.changed_files) ? workerResult.changed_files : []
|
|
const recentFiles: Array<{ path: string; size: number }> = []
|
|
if (resultFiles.length > 0) {
|
|
const { statSync: st, existsSync: ex } = await import('fs')
|
|
for (const file of resultFiles) {
|
|
if (!file || file.startsWith('.air/') || file.includes('/.air/')) continue
|
|
const full = join(project_root, file)
|
|
if (!ex(full)) continue
|
|
const s = st(full)
|
|
if (s.isFile()) recentFiles.push({ path: file, size: s.size })
|
|
}
|
|
} else {
|
|
const { readdirSync: rd, statSync: st, existsSync: ex } = await import('fs')
|
|
const scanDir = (d: string, depth: number) => {
|
|
if (depth > 3 || !ex(d)) return
|
|
try {
|
|
for (const e of rd(d)) {
|
|
if (e.startsWith('.')) continue
|
|
const p = join(d, e)
|
|
try {
|
|
const s = st(p)
|
|
if (s.isDirectory()) scanDir(p, depth + 1)
|
|
else if (s.mtimeMs > Date.now() - 60000) recentFiles.push({ path: p.replace(project_root + '/', ''), size: s.size })
|
|
} catch {}
|
|
}
|
|
} catch {}
|
|
}
|
|
scanDir(project_root, 0)
|
|
}
|
|
if (recentFiles.length > 0) {
|
|
console.log(' Produced files:')
|
|
for (const f of recentFiles.slice(0, 10)) {
|
|
console.log(` 📄 ${f.path} (${f.size}B)`)
|
|
}
|
|
}
|
|
|
|
taskResults.set(taskId, { title: input.slice(0, 80), files: recentFiles, state: finalState })
|
|
|
|
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()
|
|
})
|
|
}
|
|
|
|
// 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 }
|
|
|
|
if (pendingConfirmation) {
|
|
if (/^(y|yes|是|确认|确定)$/i.test(input)) {
|
|
const confirmedInput = pendingConfirmation
|
|
pendingConfirmation = undefined
|
|
await agent.handle_confirmation(true)
|
|
await dispatchTask(confirmedInput)
|
|
} else if (/^(n|no|否|取消)$/i.test(input)) {
|
|
pendingConfirmation = undefined
|
|
await agent.handle_confirmation(false)
|
|
console.log('Cancelled. No task was created.\n')
|
|
} else {
|
|
console.log('Please answer y/n to confirm or cancel the pending destructive request.\n')
|
|
}
|
|
rl.prompt()
|
|
return
|
|
}
|
|
|
|
// Handle slash commands
|
|
if (input.startsWith('/')) {
|
|
await handleSlashCommand(input, app, runtime, tui, rl, taskResults)
|
|
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') {
|
|
if (agent.state === 'CONFIRMING' && classification.response) {
|
|
pendingConfirmation = input
|
|
console.log('\n' + classification.response + '\n')
|
|
} else {
|
|
await dispatchTask(input)
|
|
}
|
|
} else {
|
|
console.log(`Result: ${classification.response || 'Done'}`)
|
|
}
|
|
|
|
rl.prompt()
|
|
})
|
|
|
|
rl.on('close', async () => {
|
|
console.log('\nShutting down...')
|
|
tui.stop()
|
|
await app.shutdown()
|
|
process.exit(0)
|
|
})
|
|
|
|
process.on('SIGINT', () => {
|
|
rl.close()
|
|
})
|
|
|
|
await new Promise(() => {}) // Wait forever
|
|
}
|
|
|
|
async function handleSlashCommand(input: string, app: any, runtime: any, tui: any, rl: any, taskResults?: Map<string, 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(' /results — Show produced files from completed tasks')
|
|
console.log(' /quit — Exit AirCoding\n')
|
|
break
|
|
|
|
case 'results':
|
|
if (!taskResults || taskResults.size === 0) {
|
|
console.log(' No task results yet. Submit a task first.\n')
|
|
} else {
|
|
console.log('')
|
|
for (const [taskId, result] of taskResults) {
|
|
console.log(` Task: ${result.title} [${result.state}]`)
|
|
if (result.files.length > 0) {
|
|
for (const f of result.files) {
|
|
console.log(` 📄 ${f.path} (${f.size}B)`)
|
|
}
|
|
} else {
|
|
console.log(' (no files produced)')
|
|
}
|
|
}
|
|
console.log('')
|
|
}
|
|
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`)
|
|
}
|
|
}
|