fix: integrate audit findings round 1 - tools, worker, scheduler, main agent
- Unify ToolResultEnvelope (output vs content) for built-in tools - Fix shell.run AsyncGenerator consumption in ToolRegistry.call/streaming - Scheduler: consume WorkerResult.status instead of marking all running tasks completed - WorkerProcess/WorkerManager: surface exit events and generate failed/cancelled result - MainAgent: integrate ContextAssembler, Chinese destructive regex, ArchitectureDesigner impact gate - run.ts: pendingConfirmation flow, dispatch extracted, .air files filtered from /results - CapabilityRegistry wired into RuntimeApp and ServiceRegistry; DoctorService uses it - release.ts: findRepoRoot/findBun, run air e2e + depcruise + runtime regression - New gates: release-critical-gates, CLI run command regression - 14/14 e2e gates pass; 3/3 release dry-run pass Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -54,6 +54,9 @@ export async function askCommand(prompt: string, opts?: { model?: string; maxTur
|
||||
project_id: 'ask-project',
|
||||
classify_mode: 'regex',
|
||||
provider_manager: provider as any,
|
||||
context_assembler: app.context_assembler,
|
||||
project_root: projectRoot,
|
||||
agent_id: 'ask-agent' as any,
|
||||
classify_model: model
|
||||
})
|
||||
|
||||
@@ -88,9 +91,9 @@ async function execute_task(
|
||||
const ctx = {
|
||||
session_id: 'ask-session',
|
||||
project_id: 'ask-project',
|
||||
project_root: projectRoot,
|
||||
agent_id: 'ask-agent',
|
||||
permission_template: 'main_direct' as const,
|
||||
cwd: projectRoot
|
||||
agent_type: 'executor' as const,
|
||||
}
|
||||
|
||||
const systemPrompt = `You are an AI coding assistant. Help the user by reading files, writing code, and running commands.
|
||||
|
||||
@@ -108,6 +108,7 @@ export function e2eCommand(): void {
|
||||
return { pass: false, detail: `❌ (${errCount} errors)\n ${tail}` }
|
||||
}
|
||||
}},
|
||||
{ label: 'P0: Release-critical functional gates', fn: () => runTest('P0-REL', './packages/runtime/test/regression/release-critical-gates.test.ts ./packages/cli/test/run-command-regression.test.ts', repoRoot) },
|
||||
|
||||
// P1: Storage/Events
|
||||
{ label: 'P1: Storage/Events (test)', fn: () => runTest('P1', './packages/runtime/test/storage/ ./packages/runtime/test/regression/transaction-boundary.test.ts ./packages/runtime/test/regression/event-repository-route.test.ts', repoRoot) },
|
||||
|
||||
@@ -3,6 +3,34 @@
|
||||
* DD §17. Full validation suite.
|
||||
*/
|
||||
import { execFileSync } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import { dirname, join } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
function findRepoRoot(): string {
|
||||
if (process.env.AIRCODING_REPO_ROOT && existsSync(join(process.env.AIRCODING_REPO_ROOT, 'package.json'))) {
|
||||
return process.env.AIRCODING_REPO_ROOT
|
||||
}
|
||||
let dir = dirname(fileURLToPath(import.meta.url))
|
||||
while (dir !== dirname(dir)) {
|
||||
if (existsSync(join(dir, 'package.json')) && existsSync(join(dir, 'packages'))) return dir
|
||||
dir = dirname(dir)
|
||||
}
|
||||
return process.cwd()
|
||||
}
|
||||
|
||||
function findBun(repoRoot: string): string {
|
||||
const candidates = [
|
||||
process.env.BUN_INSTALL ? join(process.env.BUN_INSTALL, 'bin', 'bun') : '',
|
||||
join(process.env.HOME || '', '.bun', 'bin', 'bun'),
|
||||
'/usr/local/bin/bun',
|
||||
'/usr/bin/bun',
|
||||
].filter(Boolean)
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
return 'bun'
|
||||
}
|
||||
|
||||
export function releaseCommand(): void {
|
||||
console.log('============================================================')
|
||||
@@ -12,17 +40,20 @@ export function releaseCommand(): void {
|
||||
let passed = 0
|
||||
let failed = 0
|
||||
|
||||
// Gate 1: typecheck
|
||||
const repoRoot = findRepoRoot()
|
||||
const bun = findBun(repoRoot)
|
||||
|
||||
// Gate 1: full e2e validation suite
|
||||
console.log('Running release readiness checks...\n')
|
||||
const g1 = runCheck('typecheck', 'node_modules/.bin/tsc', ['--noEmit', '-p', 'tsconfig.check.json'])
|
||||
const g1 = runCheck('e2e', bun, ['run', join(repoRoot, 'packages/cli/src/index.ts'), 'e2e'], repoRoot)
|
||||
if (g1) passed++; else failed++;
|
||||
|
||||
// Gate 2: test (run regression suite)
|
||||
const g2 = runCheck('test', '/home/airlongdian/.bun/bin/bun', ['test', 'packages/runtime/test/regression/'])
|
||||
// Gate 2: release regression suite
|
||||
const g2 = runCheck('runtime regression', bun, ['test', 'packages/runtime/test/regression/'], repoRoot)
|
||||
if (g2) passed++; else failed++;
|
||||
|
||||
// Gate 3: depcruise
|
||||
const g3 = runCheck('depcruise', 'node_modules/.bin/depcruise', ['--config', '.dependency-cruiser.js', 'packages/*/src'])
|
||||
// Gate 3: dependency boundary
|
||||
const g3 = runCheck('depcruise', join(repoRoot, 'node_modules/.bin/depcruise'), ['--config', join(repoRoot, '.dependency-cruiser.js'), 'packages/cli/src/', 'packages/contracts/src/', 'packages/llm/src/', 'packages/runtime/src/', 'packages/toolchain-cpp/src/', 'packages/tui/src/', 'packages/workers/src/'], repoRoot)
|
||||
if (g3) passed++; else failed++;
|
||||
|
||||
// Summary
|
||||
@@ -38,18 +69,20 @@ export function releaseCommand(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function runCheck(name: string, bin: string, args: string[]): boolean {
|
||||
function runCheck(name: string, bin: string, args: string[], cwd: string): boolean {
|
||||
console.log(` ${name}...`)
|
||||
try {
|
||||
execFileSync(bin, args, {
|
||||
cwd: process.cwd(),
|
||||
cwd,
|
||||
stdio: 'pipe',
|
||||
timeout: 120000
|
||||
timeout: 600000
|
||||
})
|
||||
console.log(` PASS`)
|
||||
return true
|
||||
} catch (e: any) {
|
||||
const output = String(e.stdout || e.stderr || e.message || '').split('\n').slice(-20).join('\n')
|
||||
console.log(` FAIL`)
|
||||
if (output.trim()) console.log(output)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -81,11 +81,18 @@ export async function runCommand(project_path?: string): Promise<void> {
|
||||
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')
|
||||
@@ -93,6 +100,89 @@ export async function runCommand(project_path?: string): Promise<void> {
|
||||
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)}`
|
||||
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()
|
||||
@@ -101,9 +191,26 @@ export async function runCommand(project_path?: string): Promise<void> {
|
||||
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)
|
||||
await handleSlashCommand(input, app, runtime, tui, rl, taskResults)
|
||||
rl.prompt()
|
||||
return
|
||||
}
|
||||
@@ -115,47 +222,12 @@ export async function runCommand(project_path?: string): Promise<void> {
|
||||
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()
|
||||
})
|
||||
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'}`)
|
||||
}
|
||||
@@ -177,17 +249,37 @@ export async function runCommand(project_path?: string): Promise<void> {
|
||||
await new Promise(() => {}) // Wait forever
|
||||
}
|
||||
|
||||
async function handleSlashCommand(input: string, app: any, runtime: any, tui: any, rl: any): Promise<void> {
|
||||
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(' /quit — Exit AirCoding\n')
|
||||
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':
|
||||
|
||||
24
packages/cli/test/run-command-regression.test.ts
Executable file
24
packages/cli/test/run-command-regression.test.ts
Executable file
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from 'bun:test'
|
||||
import { readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
describe('run command result presentation', () => {
|
||||
const source = readFileSync(join(import.meta.dir, '../src/commands/run.ts'), 'utf-8')
|
||||
|
||||
it('prefers WorkerResult.changed_files over recent filesystem scan', () => {
|
||||
expect(source).toContain('get_result_for_task')
|
||||
expect(source).toContain('workerResult?.changed_files')
|
||||
})
|
||||
|
||||
it('filters .air internals from produced files', () => {
|
||||
expect(source).toContain("file.startsWith('.air/')")
|
||||
expect(source).toContain("e.startsWith('.')")
|
||||
})
|
||||
|
||||
it('routes destructive confirmation before slash/main dispatch', () => {
|
||||
expect(source).toContain('pendingConfirmation')
|
||||
expect(source).toContain('handle_confirmation(true)')
|
||||
expect(source).toContain('handle_confirmation(false)')
|
||||
expect(source).toContain('No task was created')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user