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:
AirCoding
2026-06-05 18:39:10 +08:00
parent a2d7aa0339
commit ddefcbb2b1
24 changed files with 1992 additions and 186 deletions

View File

@@ -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.

View File

@@ -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) },

View File

@@ -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
}
}

View File

@@ -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':

View 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')
})
})

View File

@@ -8,7 +8,9 @@
* @module packages/runtime/src/agents/main/MainAgent
*/
import type { SessionID, ProjectID } from '@aircoding/contracts'
import type { SessionID, ProjectID, AgentID, TaskID } from '@aircoding/contracts'
import type { ContextAssembler } from '../../context/ContextAssembler.js'
import { ArchitectureDesigner } from '../architecture/ArchitectureDesigner.js'
export type MainAgentState =
| 'IDLE'
@@ -34,13 +36,23 @@ export interface MainAgentConfig {
project_id: ProjectID
classify_mode?: ClassifyMode // Alpha default: 'regex'; set to 'llm' to use LLM classification
provider_manager?: any // ProviderManager for LLM-based classify
classify_model?: string // Model to use for LLM classification (e.g. 'claude-haiku-4-5')
context_assembler?: ContextAssembler
architecture_designer?: ArchitectureDesigner
project_root?: string
agent_id?: AgentID
task_id?: TaskID
classify_model?: string // Model to use for LLM classification and answer mode
}
export class MainAgent {
private config: MainAgentConfig
private classify_mode: ClassifyMode
private provider_manager?: any
private context_assembler?: ContextAssembler
private architecture_designer: ArchitectureDesigner
private project_root: string
private agent_id: AgentID
private task_id?: TaskID
private classify_model: string
state: MainAgentState = 'IDLE'
@@ -48,6 +60,11 @@ export class MainAgent {
this.config = config
this.classify_mode = config.classify_mode || 'regex'
this.provider_manager = config.provider_manager
this.context_assembler = config.context_assembler
this.architecture_designer = config.architecture_designer || new ArchitectureDesigner()
this.project_root = config.project_root || process.cwd()
this.agent_id = config.agent_id || 'main-agent' as AgentID
this.task_id = config.task_id
this.classify_model = config.classify_model || 'claude-haiku-4-5'
}
@@ -75,10 +92,19 @@ export class MainAgent {
case 'implementation_request':
case 'task_request':
// Check if this request needs user confirmation (breaking/delete)
if (/break|delete|remove|drop|destroy|truncate/i.test(message)) {
if (/break|delete|remove|drop|destroy|truncate|rm\s|\bdel\b/i.test(message) || /删除|删掉|清除|移除|销毁/.test(message)) {
this.state = 'CONFIRMING'
return { action: 'delegate', response: 'This appears to be a breaking or destructive change. Are you sure you want to proceed? (y/n)' }
}
const impact = this.architecture_designer.assess_impact({ description: message, files: this.infer_changed_files(message) })
if (impact.result === 'reject_or_escalate' || impact.result === 'requires_replan') {
this.state = 'ARCHITECTURE_DESIGNING'
return { action: 'answer', response: `Architecture review required: ${impact.risks.join('; ') || impact.change_summary}` }
}
if (impact.result === 'requires_user_confirmation') {
this.state = 'CONFIRMING'
return { action: 'delegate', response: `Architecture impact requires confirmation: ${impact.risks.join('; ') || impact.change_summary}. Proceed? (y/n)` }
}
this.state = 'DELEGATING'
return { action: 'delegate', tasks: ['task-1'] }
@@ -105,10 +131,25 @@ export class MainAgent {
}
try {
const messages = [
{ role: 'system', content: 'You are AirCoding, an AI coding assistant. Help the user with their coding tasks. Be concise and helpful.' },
{ role: 'user', content: user_message }
]
const assembled = this.context_assembler?.assemble({
session_id: this.config.session_id,
project_id: this.config.project_id,
project_root: this.project_root,
agent_id: this.agent_id,
agent_type: 'executor',
task_id: this.task_id,
token_budget: 200000,
})
const messages = assembled?.messages?.length
? [
...assembled.messages,
{ role: 'user', content: user_message }
]
: [
{ role: 'system', content: 'You are AirCoding, an AI coding assistant. Help the user with their coding tasks. Be concise and helpful.' },
{ role: 'user', content: user_message }
]
const result = await this.provider_manager.complete_text(messages, {
model: this.classify_model,
@@ -182,9 +223,9 @@ export class MainAgent {
].join('\n')
try {
const result = await this.provider_manager.complete(
const result = await this.provider_manager.complete_text(
[{ role: 'user', content: classification_prompt }],
{ model: this.classify_model }
{ model: this.classify_model, max_tokens: 32 }
)
const parsed = String(result.content || '').trim().toLowerCase()
if (parsed === 'simple_question' || parsed === 'implementation_request' || parsed === 'direct_command') {
@@ -198,6 +239,14 @@ export class MainAgent {
}
}
private infer_changed_files(message: string): string[] {
const files = Array.from(message.matchAll(/[\w./-]+\.(?:ts|tsx|js|jsx|json|md|cpp|c|h|hpp|cmake|txt|yaml|yml)/g)).map(m => m[0])
if (/contract|协议|契约/i.test(message)) files.push('packages/contracts/src/index.ts')
if (/runtime|调度|scheduler|worker/i.test(message)) files.push('packages/runtime/src/index.ts')
if (/tui|hud|界面/i.test(message)) files.push('packages/tui/src/index.ts')
return [...new Set(files)]
}
/**
* Handle confirmation from user.
*/

View File

@@ -24,6 +24,9 @@ import { EventBus } from '../events/EventBus.js'
import { EventStore, eventStore } from '../events/EventStore.js'
import { EventIngestorImpl } from '../events/EventIngestor.js'
import { TaskRepository } from '../storage/repositories/TaskRepository.js'
import { MessageRepository } from '../storage/repositories/MessageRepository.js'
import { EvidenceRepository } from '../storage/repositories/EvidenceRepository.js'
import { createCapabilityRegistry, type CapabilityRegistry } from '../capabilities/CapabilityRegistry.js'
export interface RuntimeAppConfig {
project_root: string
@@ -43,6 +46,7 @@ export class RuntimeApp {
logger: Logger
db: DatabaseManager
tool_registry: ToolRegistry
capability_registry: CapabilityRegistry
get session_id(): SessionID { return this.config.session_id }
get project_id(): ProjectID { return this.config.project_id }
@@ -64,9 +68,11 @@ export class RuntimeApp {
// Core services
this.tool_registry = createToolRegistry(config.project_root)
this.capability_registry = createCapabilityRegistry()
this.capability_registry.set_tool_registry(this.tool_registry)
this.worker_manager = new WorkerManager(this.tool_registry)
this.context_assembler = new ContextAssembler()
this.doctor = new DoctorService(config.project_root)
this.doctor = new DoctorService(config.project_root, this.capability_registry)
this.projection_store = new ProjectionStore()
this.projection_client = new ProjectionClient()
this.event_bus = new EventBus()
@@ -145,6 +151,9 @@ export class RuntimeApp {
const raw_db = this.db.getRawDatabase()
if (raw_db) {
const task_repo = new TaskRepository(raw_db as any)
const message_repo = new MessageRepository(raw_db as any)
const evidence_repo = new EvidenceRepository(raw_db as any)
this.context_assembler.set_data_sources({ message_repo, evidence_store: evidence_repo })
this.scheduler.set_task_repo(task_repo)
const rehydrated = await this.scheduler.rebuild_from_db()
this.logger.info('Scheduler recovery complete', { rehydrated })

View File

@@ -14,6 +14,7 @@ import { ProjectionStore } from '../projection/ProjectionStore.js'
import { Logger } from '../logging/Logger.js'
import { Scheduler } from '../scheduler/Scheduler.js'
import { WorkerManager } from '../workers/WorkerManager.js'
import { createCapabilityRegistry, type CapabilityRegistry } from '../capabilities/CapabilityRegistry.js'
export interface ServiceGraph {
database: DatabaseManager
@@ -21,6 +22,7 @@ export interface ServiceGraph {
tool_registry: ToolRegistry
context_assembler: ContextAssembler
doctor: DoctorService
capability_registry: CapabilityRegistry
projection_store: ProjectionStore
logger: Logger
scheduler: Scheduler | null
@@ -38,11 +40,13 @@ export class ServiceRegistry {
const database = new DatabaseManager(`${project_root}/.air/sessions/${session_id}.db`)
const permission_engine = new PermissionEngine(project_root)
const tool_registry = new ToolRegistry(project_root)
const capability_registry = createCapabilityRegistry()
capability_registry.set_tool_registry(tool_registry)
const context_assembler = new ContextAssembler()
const doctor = new DoctorService(project_root)
const doctor = new DoctorService(project_root, capability_registry)
const projection_store = new ProjectionStore()
const worker_manager = new WorkerManager()
const scheduler = new Scheduler({ session_id, project_id, project_root })
const worker_manager = new WorkerManager(tool_registry)
const scheduler = new Scheduler({ session_id, project_id, project_root }, worker_manager)
// Register all services
this.services.set('database', database)
@@ -50,6 +54,7 @@ export class ServiceRegistry {
this.services.set('tool_registry', tool_registry)
this.services.set('context_assembler', context_assembler)
this.services.set('doctor', doctor)
this.services.set('capability_registry', capability_registry)
this.services.set('projection_store', projection_store)
this.services.set('logger', logger)
this.services.set('scheduler', scheduler)
@@ -57,7 +62,7 @@ export class ServiceRegistry {
return {
database, permission_engine, tool_registry,
context_assembler, doctor, projection_store,
context_assembler, doctor, capability_registry, projection_store,
logger, scheduler, worker_manager
}
}

View File

@@ -11,6 +11,8 @@ import type {
SessionID, ProjectID, AgentID, TaskID, ArtifactID, ISOTimeString
} from '@aircoding/contracts'
import { readdirSync, statSync } from 'fs'
import { join, relative } from 'path'
import { PromptLayerLoader, createPromptLayerLoader } from './PromptLayerLoader.js'
import { CompactionPolicy, createCompactionPolicy } from './CompactionPolicy.js'
@@ -104,6 +106,32 @@ export class ContextAssembler {
}
}
private build_project_files_snapshot(project_root: string): string {
const files: string[] = []
const ignored = new Set(['.air', '.git', 'node_modules', 'build', 'dist', '.claude'])
const walk = (dir: string, depth: number) => {
if (depth > 3 || files.length >= 200) return
let entries: string[] = []
try {
entries = readdirSync(dir)
} catch {
return
}
for (const entry of entries) {
if (ignored.has(entry)) continue
const full = join(dir, entry)
try {
const stat = statSync(full)
if (stat.isDirectory()) walk(full, depth + 1)
else files.push(relative(project_root, full))
} catch {}
if (files.length >= 200) return
}
}
walk(project_root, 0)
return ['# Project Files Snapshot (L3)', ...files.map(f => `- ${f}`)].join('\n')
}
/**
* Collect all layers in order L0-L9.
*/
@@ -132,6 +160,13 @@ export class ContextAssembler {
project_root: context.project_root
})
layers.push(...project_rules)
layers.push({
level: 'project_files' as any,
priority: 3,
content: this.build_project_files_snapshot(context.project_root),
token_estimate: 300,
source_ref: `project:${context.project_id}:files`
})
// L4: Architecture (if available)
if (context.additional_layers) {
@@ -162,7 +197,7 @@ export class ContextAssembler {
let evidence_content = ''
if (this.evidence_store && context.task_id) {
try {
const records = this.evidence_store.list_for_entity?.(context.task_id) || []
const records = this.evidence_store.list_for_entity?.('task_id', context.task_id) || []
if (records.length > 0) {
evidence_content = records.map((r: any) =>
`- [${r.type || 'evidence'}] ${r.summary || r.id}`).join('\n')
@@ -218,9 +253,9 @@ export class ContextAssembler {
if (this.message_repo) {
try {
const msgs = this.message_repo.list_by_session?.(context.session_id) || []
const tool_msgs = msgs.filter((m: any) => m.role === 'tool_result' || m.role === 'tool_use').slice(-10)
const tool_msgs = msgs.filter((m: any) => m.role === 'tool' || m.role === 'tool_result' || m.role === 'tool_use').slice(-10)
tool_content = tool_msgs.map((m: any) =>
`[${m.role}]: ${String(m.content_json || '').slice(0, 300)}`).join('\n')
`[${m.role}]: ${String(m.content_json || m.content || '').slice(0, 300)}`).join('\n')
} catch { /* fall through */ }
}
layers.push({
@@ -248,12 +283,6 @@ export class ContextAssembler {
})
}
// Add any additional layers
if (context.additional_layers) {
const others = context.additional_layers.filter(l => l.level !== 'architecture')
layers.push(...others)
}
return layers
}
@@ -265,7 +294,7 @@ export class ContextAssembler {
// System message: L0 + L1 + L2 + L3
const system_content = layers
.filter(l => ['runtime_invariant', 'role', 'safety', 'project_rules'].includes(l.level))
.filter(l => ['runtime_invariant', 'role', 'safety', 'project_rules', 'project_files'].includes(l.level))
.map(l => l.content)
.join('\n\n---\n\n')

View File

@@ -248,13 +248,79 @@ export class Scheduler {
}
}
// Workers complete → mark running tasks as completed
if (this.worker_manager && !this.worker_manager.has_running()) {
// Mark ALL running tasks as completed (not just runnable)
const all_tasks = Array.from(this.graph['tasks']?.values() || [])
for (const t of all_tasks) {
if ((t as any).status === 'running') {
this.graph.update_status((t as any).id, 'completed')
// Workers complete → consume explicit WorkerResult status
if (this.worker_manager) {
const running_tasks = this.graph.get_tasks_by_status('running')
for (const task of running_tasks) {
const handle = this.worker_manager.get_handle_for_task(task.id)
const result = this.worker_manager.get_result_for_task(task.id)
if (!handle || !result) continue
const attempt_id = `${task.id}_1`
if (result.status === 'completed') {
await eventIngestor.ingest({
id: `evt_${task.id}_completed`,
type: 'task.completed',
version: 1,
session_id: this.context.session_id,
project_id: this.context.project_id,
timestamp: new Date().toISOString(),
source: { kind: 'scheduler' },
route: ['scheduler', 'monitoring'],
payload: {
task_id: task.id,
agent_id: handle.worker_id,
attempt_id,
worker_result_json: result,
summary: result.summary,
changed_files: result.changed_files,
evidence_refs: result.evidence_refs,
}
})
this.graph.update_status(task.id, 'completed')
this.agent_monitor.remove(handle.worker_id)
} else if (result.status === 'blocked') {
await eventIngestor.ingest({
id: `evt_${task.id}_blocked`,
type: 'task.blocked',
version: 1,
session_id: this.context.session_id,
project_id: this.context.project_id,
timestamp: new Date().toISOString(),
source: { kind: 'scheduler' },
route: ['scheduler', 'monitoring'],
payload: { task_id: task.id, agent_id: handle.worker_id, reason: result.summary, blocker_kind: 'worker_blocked', evidence_refs: result.evidence_refs, suggested_next_step: 'Review worker blocker and retry with corrected plan' }
})
this.graph.update_status(task.id, 'blocked')
this.agent_monitor.remove(handle.worker_id)
} else if (result.status === 'cancelled') {
await eventIngestor.ingest({
id: `evt_${task.id}_cancelled_result`,
type: 'task.cancelled',
version: 1,
session_id: this.context.session_id,
project_id: this.context.project_id,
timestamp: new Date().toISOString(),
source: { kind: 'scheduler' },
route: ['scheduler', 'monitoring'],
payload: { task_id: task.id, reason: result.summary, cancelled_by: handle.worker_id }
})
this.graph.update_status(task.id, 'cancelled')
this.agent_monitor.remove(handle.worker_id)
} else {
await eventIngestor.ingest({
id: `evt_${task.id}_failed_result`,
type: 'task.failed',
version: 1,
session_id: this.context.session_id,
project_id: this.context.project_id,
timestamp: new Date().toISOString(),
source: { kind: 'scheduler' },
route: ['scheduler', 'monitoring'],
payload: { task_id: task.id, agent_id: handle.worker_id, attempt_id, error: { message: result.summary }, evidence_refs: result.evidence_refs, metadata: { worker_status: result.status } }
})
this.graph.update_status(task.id, 'failed')
this.agent_monitor.remove(handle.worker_id)
}
}
}

View File

@@ -178,7 +178,7 @@ export class BuiltInToolRegistrar {
const { path } = call.arguments as { path: string }
const s = statSync(resolve(project_root, path))
return { status: "ok", call_id: call.call_id, tool_name: 'fs.stat', type: 'text',
content: { path, exists: true, size: s.size, is_dir: s.isDirectory(), is_file: s.isFile(),
output: { path, exists: true, size: s.size, is_dir: s.isDirectory(), is_file: s.isFile(),
mode: s.mode, mtime: s.mtime.toISOString(), ctime: s.ctime.toISOString() },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name: 'fs.stat', type: 'ok' } }
} catch (e: any) {
@@ -192,7 +192,7 @@ export class BuiltInToolRegistrar {
const { pid, signal = 'SIGTERM' } = call.arguments as { pid: number; signal?: string }
process.kill(pid, signal as NodeJS.Signals)
return { status: "ok", call_id: call.call_id, tool_name: 'process.kill', type: 'text',
content: { pid, signal, killed: true },
output: { pid, signal, killed: true },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name: 'process.kill', type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: 'process.kill' },
@@ -204,7 +204,7 @@ export class BuiltInToolRegistrar {
try {
const { path, base_ref = 'HEAD' } = call.arguments as { path: string; base_ref?: string }
execFileSync('git', ['worktree', 'add', path, base_ref], { cwd: project_root, stdio: 'pipe', encoding: 'utf-8' })
return { status: "ok", call_id: call.call_id, tool_name, type: 'text', content: { path, base_ref, created: true },
return { status: "ok", call_id: call.call_id, tool_name, type: 'text', output: { path, base_ref, created: true },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
@@ -216,7 +216,7 @@ export class BuiltInToolRegistrar {
try {
const { workspace_id } = call.arguments as { workspace_id: string; strategy?: string }
execFileSync('git', ['merge', '--no-ff', workspace_id], { cwd: project_root, stdio: 'pipe', encoding: 'utf-8' })
return { status: "ok", call_id: call.call_id, tool_name, type: 'text', content: { workspace_id, merged: true },
return { status: "ok", call_id: call.call_id, tool_name, type: 'text', output: { workspace_id, merged: true },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
@@ -235,7 +235,7 @@ export class BuiltInToolRegistrar {
by_ext[ext] = (by_ext[ext] || 0) + 1
}
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
content: { root: dir, total_files: entries.length, extensions: by_ext },
output: { root: dir, total_files: entries.length, extensions: by_ext },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
@@ -249,7 +249,7 @@ export class BuiltInToolRegistrar {
const dir = join(project_root, '.air', 'shared', 'profiles')
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, `${language}.json`), JSON.stringify(profile_json, null, 2))
return { status: "ok", call_id: call.call_id, tool_name, type: 'text', content: { language, written: true },
return { status: "ok", call_id: call.call_id, tool_name, type: 'text', output: { language, written: true },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
@@ -263,7 +263,7 @@ export class BuiltInToolRegistrar {
const cmake = existsSync(join(root, 'CMakeLists.txt'))
const makefile = existsSync(join(root, 'Makefile'))
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
content: { has_cmake: cmake, has_makefile: makefile, build_system: cmake ? 'cmake' : makefile ? 'make' : 'none' },
output: { has_cmake: cmake, has_makefile: makefile, build_system: cmake ? 'cmake' : makefile ? 'make' : 'none' },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
@@ -277,7 +277,7 @@ export class BuiltInToolRegistrar {
const buildDir = join(project_root, 'build')
if (!existsSync(buildDir)) mkdirSync(buildDir, { recursive: true })
execFileSync('cmake', ['-G', generator, '-DCMAKE_BUILD_TYPE=' + build_type, '..'], { cwd: buildDir, stdio: 'pipe' })
return { status: "ok", call_id: call.call_id, tool_name, type: 'text', content: { generator, build_type, configured: true },
return { status: "ok", call_id: call.call_id, tool_name, type: 'text', output: { generator, build_type, configured: true },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
@@ -291,7 +291,7 @@ export class BuiltInToolRegistrar {
const args = target ? ['--build', '.', '--config', config, '--target', target] : ['--build', '.', '--config', config]
const out = execFileSync('cmake', args, { cwd: join(project_root, 'build'), stdio: 'pipe', timeout: 300000 })
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
content: { built: true, output: out.toString().slice(-500) },
output: { built: true, output: out.toString().slice(-500) },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
@@ -305,7 +305,7 @@ export class BuiltInToolRegistrar {
const args = filter ? ['--output-on-failure', '-R', filter] : ['--output-on-failure']
const out = execFileSync('ctest', args, { cwd: join(project_root, 'build'), stdio: 'pipe', timeout: 300000 })
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
content: { passed: true, output: out.toString().slice(-1000) },
output: { passed: true, output: out.toString().slice(-1000) },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
@@ -318,7 +318,7 @@ export class BuiltInToolRegistrar {
const { path = 'src' } = (call.arguments || {}) as any
const out = execFileSync('cppcheck', ['--enable=all', '--quiet', path], { cwd: project_root, stdio: 'pipe', encoding: 'utf-8', timeout: 120000 })
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
content: { output: out.toString().slice(-500), issues_found: 0 },
output: { output: out.toString().slice(-500), issues_found: 0 },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
@@ -331,7 +331,7 @@ export class BuiltInToolRegistrar {
const { file, line = 0, column = 0 } = (call.arguments || {}) as any
const out = execFileSync('clangd', ['--check=' + file], { cwd: project_root, stdio: 'pipe', encoding: 'utf-8', timeout: 30000 })
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
content: { file, line, column, diagnostics: out.toString().slice(-1000) },
output: { file, line, column, diagnostics: out.toString().slice(-1000) },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
@@ -344,7 +344,7 @@ export class BuiltInToolRegistrar {
const { target } = (call.arguments || {}) as any
const out = execFileSync('gdb', ['-batch', '-ex', 'run', '-ex', 'bt', '--', target], { cwd: project_root, stdio: 'pipe', encoding: 'utf-8', timeout: 60000 })
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
content: { target, backtrace: out.toString().slice(-2000) },
output: { target, backtrace: out.toString().slice(-2000) },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
@@ -358,7 +358,7 @@ export class BuiltInToolRegistrar {
const content = readFileSync(resolve(project_root, log_path), 'utf-8')
const errors = content.split('\n').filter(l => /error|fail|segfault|assert|abort|exception/i.test(l)).slice(0, 50)
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
content: { log_path, error_count: errors.length, errors },
output: { log_path, error_count: errors.length, errors },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
@@ -373,7 +373,7 @@ export class BuiltInToolRegistrar {
const tmpFile = join(tmpDir, `screenshot-${Date.now()}.png`)
execFileSync('import', ['-window', 'root', tmpFile], { stdio: 'pipe', timeout: 10000 })
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
content: { captured: true, path: tmpFile },
output: { captured: true, path: tmpFile },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: `Screenshot not available: ${e.message}`, retryability: 'not_retryable', semantic_signature: tool_name },
@@ -388,7 +388,7 @@ export class BuiltInToolRegistrar {
if (filter) args.push(filter)
const out = execFileSync('tcpdump', args, { stdio: 'pipe', timeout: (duration_sec + 5) * 1000 })
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
content: { interface: iface, duration_sec, packets: (String(out) || '').split('\n').length },
output: { interface: iface, duration_sec, packets: (String(out) || '').split('\n').length },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: `Capture not available: ${e.message}`, retryability: 'not_retryable', semantic_signature: tool_name },
@@ -399,7 +399,7 @@ export class BuiltInToolRegistrar {
'permission.request': async (call: any) => {
const { tool_name: tn, reason } = call.arguments as { tool_name: string; reason: string }
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
content: { tool_name: tn, reason, status: 'allowed', message: `Permission granted for ${tn}: ${reason}` },
output: { tool_name: tn, reason, status: 'allowed', message: `Permission granted for ${tn}: ${reason}` },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
},
@@ -416,7 +416,7 @@ export class BuiltInToolRegistrar {
checks.push({ name: 'project_structure', passed: hasPkg, message: hasPkg ? 'Valid' : 'No package.json' })
const allPassed = checks.every(c => c.passed)
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
content: { checks, all_passed: allPassed, fixable_count: checks.filter(c => !c.passed).length },
output: { checks, all_passed: allPassed, fixable_count: checks.filter(c => !c.passed).length },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
@@ -432,7 +432,7 @@ export class BuiltInToolRegistrar {
call_id: call.call_id,
tool_name,
type: 'text',
content: { message: `Tool ${tool_name} not yet implemented` },
output: { message: `Tool ${tool_name} not yet implemented` },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' }
})
}

View File

@@ -12,8 +12,13 @@ import type { ToolDefinition, ToolCall, ToolResultEnvelope, ISOTimeString } from
import { PermissionEngine, createPermissionEngine, type PermissionContext, type PermissionDecision, type PermissionAction } from '../security/PermissionEngine.js'
import type { AgentType } from '@aircoding/contracts'
export type ToolExecutionReturn =
| ToolResultEnvelope
| Promise<ToolResultEnvelope>
| AsyncIterable<ToolResultEnvelope>
export interface ToolExecutor {
(call: ToolCall, context: ToolExecutionContext): Promise<ToolResultEnvelope>
(call: ToolCall, context: ToolExecutionContext): ToolExecutionReturn
}
export interface ToolExecutionContext {
@@ -140,28 +145,19 @@ export class ToolRegistry {
const permission_context = this.build_permission_context(call, context)
const decision = await this.permission_engine.evaluate(call, permission_context, definition)
if (decision.action !== 'allow') {
if (decision.action !== 'allow' && decision.action !== 'announce_then_run') {
yield create_error_result(call.call_id, 'permission_denied', decision.reason)
return
}
// Execute with streaming support
// The executor yields intermediate results, final result comes at end
let final_result: ToolResultEnvelope | undefined
let saw_final = false
for await (const chunk of this.execute_streaming(call, context, executor)) {
// Streaming signal: final result is the one with status='ok' whose metadata marks final
if (chunk.metadata && (chunk.metadata as any).is_final === true) {
final_result = chunk
} else {
yield chunk
}
if (chunk.metadata && (chunk.metadata as any).is_final === true) saw_final = true
yield chunk
}
// Yield final result exactly once
if (final_result) {
yield final_result
} else {
if (!saw_final) {
yield create_error_result(call.call_id, 'no_final_result', 'Streaming tool did not produce final result')
}
}
@@ -251,7 +247,7 @@ export class ToolRegistry {
if (!executor) {
return create_error_result(call.call_id, 'executor_not_found', 'Executor not registered')
}
return executor(call, ctx)
return this.execute_executor_final(executor, call, ctx)
}
case 'announce_then_run': {
@@ -260,7 +256,7 @@ export class ToolRegistry {
if (!executor) {
return create_error_result(call.call_id, 'executor_not_found', 'Executor not registered')
}
const result = await executor(call, ctx)
const result = await this.execute_executor_final(executor, call, ctx)
return {
...result,
metadata: { ...result.metadata, announced: true },
@@ -269,10 +265,10 @@ export class ToolRegistry {
case 'ask_user':
// Suspend; emit permission.prompt.requested
return create_error_result('', 'user_prompt_required', 'User confirmation required')
return create_error_result(call.call_id, 'user_prompt_required', 'User confirmation required')
case 'deny':
return create_error_result('', 'permission_denied', decision.reason)
return create_error_result(call.call_id, 'permission_denied', decision.reason)
case 'block': {
// Return blocked outcome → task.blocked upstream
@@ -289,6 +285,34 @@ export class ToolRegistry {
}
}
/**
* Execute a tool and return the final envelope.
* Streaming executors are consumed until their final result.
*/
private async execute_executor_final(
executor: ToolExecutor,
call: ToolCall,
context: ToolExecutionContext,
): Promise<ToolResultEnvelope> {
const result = executor(call, context)
if (this.is_async_iterable(result)) {
let final_result: ToolResultEnvelope | undefined
let last_chunk: ToolResultEnvelope | undefined
for await (const chunk of result) {
last_chunk = chunk
if (chunk.metadata && (chunk.metadata as any).is_final === true) {
final_result = chunk
}
}
return final_result || last_chunk || create_error_result(call.call_id, 'no_result', 'Tool produced no result')
}
return await result
}
private is_async_iterable(value: unknown): value is AsyncIterable<ToolResultEnvelope> {
return Boolean(value && typeof (value as any)[Symbol.asyncIterator] === 'function')
}
/**
* Execute streaming tool.
*/
@@ -297,10 +321,12 @@ export class ToolRegistry {
context: ToolExecutionContext,
executor: ToolExecutor
): AsyncGenerator<ToolResultEnvelope> {
// Tool-specific execution handler
// For now, just execute normally
const result = await executor(call, context)
yield result
const result = executor(call, context)
if (this.is_async_iterable(result)) {
for await (const chunk of result) yield chunk
return
}
yield await result
}
}

View File

@@ -43,14 +43,12 @@ export function createShellExecutor(project_root: string) {
const cwd = workdir || project_root
const timestamp = new Date().toISOString() as ISOTimeString
// Emit command.started event
yield {
status: 'ok',
output: { event: 'command.started', command, cwd },
metadata: { timestamp, streaming: true, call_id: call.call_id, tool_name: 'shell.run' }
}
// Execute command
const proc = spawn(command, [], {
cwd,
shell: true,
@@ -59,53 +57,81 @@ export function createShellExecutor(project_root: string) {
let stdout = ''
let stderr = ''
let final_code = 0
let timed_out = false
const chunks: ToolResultEnvelope[] = []
// Stream stdout
proc.stdout.on('data', (data) => {
const text = data.toString()
stdout += text
// Emit streaming stdout
// Note: In actual implementation, this would go through EventBus
chunks.push({
status: 'ok',
output: { event: 'command.stdout', text },
metadata: { timestamp: new Date().toISOString() as ISOTimeString, streaming: true, call_id: call.call_id, tool_name: 'shell.run' }
})
})
// Stream stderr
proc.stderr.on('data', (data) => {
const text = data.toString()
stderr += text
chunks.push({
status: 'ok',
output: { event: 'command.stderr', text },
metadata: { timestamp: new Date().toISOString() as ISOTimeString, streaming: true, call_id: call.call_id, tool_name: 'shell.run' }
})
})
// Wait for completion or timeout
let timed_out = false
const timeoutPromise = new Promise<number>((resolve) => {
setTimeout(() => {
timed_out = true
proc.kill('SIGKILL')
resolve(124) // standard timeout exit code
}, timeout)
const timeout_id = setTimeout(() => {
timed_out = true
proc.kill('SIGKILL')
}, timeout)
const exit_code = await new Promise<number>((resolve) => {
proc.on('exit', (code) => resolve(code ?? 0))
proc.on('error', () => resolve(1))
})
clearTimeout(timeout_id)
const exitCode = await Promise.race([
new Promise<number>((resolve) => proc.on('exit', (code) => resolve(code || 0))),
timeoutPromise
])
if (stdout) {
yield {
status: 'ok',
output: { event: 'command.stdout', text: stdout.slice(-50000) },
metadata: { timestamp: new Date().toISOString() as ISOTimeString, streaming: true, call_id: call.call_id, tool_name: 'shell.run' }
}
}
if (stderr) {
yield {
status: 'ok',
output: { event: 'command.stderr', text: stderr.slice(-10000) },
metadata: { timestamp: new Date().toISOString() as ISOTimeString, streaming: true, call_id: call.call_id, tool_name: 'shell.run' }
}
}
while (chunks.length > 0) {
yield chunks.shift()!
}
final_code = exitCode
if (timed_out) {
stderr += `\n[Command timed out after ${timeout}ms]`
}
// Emit command.completed event
yield {
status: final_code === 0 ? 'ok' : 'error',
status: exit_code === 0 ? 'ok' : 'error',
output: {
event: 'command.completed',
exit_code: final_code,
stdout: stdout.slice(-50000), // Last 50KB
stderr: stderr.slice(-10000), // Last 10KB
exit_code,
stdout: stdout.slice(-50000),
stderr: stderr.slice(-10000),
timed_out
},
metadata: { timestamp: new Date().toISOString() as ISOTimeString, streaming: false }
error: exit_code === 0 ? undefined : {
error_id: call.call_id,
kind: 'tool_error',
severity: 'error',
message: timed_out ? `Command timed out after ${timeout}ms` : `Command exited with code ${exit_code}`,
retryability: timed_out ? 'retryable' : 'not_retryable',
semantic_signature: 'shell.run'
},
metadata: { timestamp: new Date().toISOString() as ISOTimeString, streaming: false, is_final: true, call_id: call.call_id, tool_name: 'shell.run' }
}
}
}

View File

@@ -31,7 +31,7 @@ export interface WorkerHandle {
worker_id: string
process: WorkerProcess
config: WorkerConfig
state: 'starting' | 'ready' | 'running' | 'completed' | 'error' | 'cancelled'
state: 'starting' | 'ready' | 'running' | 'completed' | 'failed' | 'error' | 'cancelled'
started_at: string
completed_at?: string
result?: WorkerResult<unknown>
@@ -107,6 +107,8 @@ export class WorkerManager {
proc.set_process(child)
proc.on_exit((exit) => this.handle_worker_exit(config.agent_id, exit))
// Set up handlers for worker IPC messages
this.setup_worker_handlers(proc, config.agent_id)
@@ -179,10 +181,10 @@ export class WorkerManager {
{
session_id: this.execution_context?.session_id || msg.session_id,
project_id: this.execution_context?.project_id || '',
project_root: this.execution_context?.project_root || process.cwd(),
agent_id,
permission_template: 'executor',
cwd: this.execution_context?.project_root || process.cwd()
} as any
agent_type: 'executor',
}
)
this.send_to_worker(agent_id, 'tool.result', {
@@ -238,8 +240,11 @@ export class WorkerManager {
proc.on_message('worker.result', (msg) => {
const handle = this.workers.get(agent_id)
if (handle) {
handle.state = 'completed'
handle.result = this.wrap_worker_result(msg.payload, handle)
handle.state = handle.result.status === 'completed' ? 'completed'
: handle.result.status === 'cancelled' ? 'cancelled'
: 'failed'
handle.completed_at = new Date().toISOString()
}
})
@@ -313,29 +318,84 @@ export class WorkerManager {
return handle.result
}
/**
* Get result for a task.
*/
get_result_for_task(task_id: string): WorkerResult<unknown> | undefined {
return this.list().find(w => w.config.task_spec?.id === task_id)?.result
}
/**
* Get handle for a task.
*/
get_handle_for_task(task_id: string): WorkerHandle | undefined {
return this.list().find(w => w.config.task_spec?.id === task_id)
}
// ============================================================================
// Private
// ============================================================================
private handle_worker_exit(agent_id: string, exit: { code: number | null; signal: NodeJS.Signals | null; semantic: string; description: string }): void {
const handle = this.workers.get(agent_id)
if (!handle) return
if (handle.result) return
const task_id = (handle.config.task_spec?.id as string) || `${agent_id}_task`
const cancelled = exit.semantic === 'parent_cancelled'
handle.state = cancelled ? 'cancelled' : 'failed'
handle.completed_at = new Date().toISOString()
handle.result = {
task_id: task_id as any,
agent_id: handle.config.agent_id as any,
agent_type: 'executor',
status: cancelled ? 'cancelled' : 'failed',
summary: `Worker exited without result: ${exit.semantic}`,
changed_files: [],
artifacts: [],
verification: [],
risks: [],
follow_up_tasks: [],
evidence_refs: [],
result: { exit },
}
}
/**
* Wrap a raw worker payload into a properly typed WorkerResult envelope.
* Provides safe defaults for any missing fields.
*/
private wrap_worker_result(payload: Record<string, unknown>, handle: WorkerHandle): WorkerResult<unknown> {
const raw_status = (payload.status as string) || 'completed'
const status = raw_status === 'completed' || raw_status === 'cancelled' || raw_status === 'blocked' || raw_status === 'failed'
? raw_status
: raw_status === 'fixed' || raw_status === 'pass'
? 'completed'
: 'failed'
const changes = Array.isArray((payload as any).changes) ? (payload as any).changes : []
const changed_files = (payload.changed_files as string[] | undefined) || changes.map((c: any) => String(c.file)).filter(Boolean)
const verification_payload = payload.verification as any
const verification = Array.isArray(verification_payload) ? verification_payload
: verification_payload ? [{ command: 'worker verification', passed: Boolean(verification_payload.passed), output: String(verification_payload.output || '') }] as any[]
: []
const summary = (payload.summary as string)
|| (payload.error ? String(payload.error) : '')
|| (changed_files.length > 0 ? `Changed files: ${changed_files.join(', ')}` : `Worker ${status}`)
return {
task_id: (payload.task_id as string) || '' as any,
task_id: (payload.task_id as string) || (handle.config.task_spec?.id as string) || '' as any,
agent_id: (payload.agent_id as string) || handle.config.agent_id as any,
agent_type: (payload.agent_type as AgentType) || 'executor',
status: (payload.status as WorkerStatus) || 'completed',
summary: (payload.summary as string) || '',
changed_files: (payload.changed_files as string[]) || [],
status: status as WorkerStatus,
summary,
changed_files,
diff_ref: (payload.diff_ref as string | undefined) || undefined,
artifacts: (payload.artifacts as any[]) || [],
verification: (payload.verification as any[]) || [],
verification,
risks: (payload.risks as any[]) || [],
follow_up_tasks: (payload.follow_up_tasks as any[]) || [],
evidence_refs: (payload.evidence_refs as any[]) || [],
result: (payload.result as unknown) || null,
result: (payload.result as unknown) || payload,
}
}

View File

@@ -35,6 +35,7 @@ export class WorkerProcess {
private proc: ChildProcess | null = null
private protocol: WorkerProtocol
private message_handlers: Map<string, (msg: WorkerMessage) => void> = new Map()
private exit_handlers: Array<(info: { code: number | null; signal: NodeJS.Signals | null; semantic: string; description: string }) => void> = []
private buffer: string = ''
constructor() {
@@ -68,6 +69,13 @@ export class WorkerProcess {
this.message_handlers.set(type, handler)
}
/**
* Register an exit handler.
*/
on_exit(handler: (info: { code: number | null; signal: NodeJS.Signals | null; semantic: string; description: string }) => void): void {
this.exit_handlers.push(handler)
}
/**
* Get exit code info.
*/
@@ -124,8 +132,13 @@ export class WorkerProcess {
// Exit handler
this.proc.on('exit', (code, signal) => {
const info = this.get_exit_code_info(code || 1)
console.log(`[Worker] exited with code ${code} (${info?.semantic || 'unknown'}): ${info?.description || ''}`)
const info = this.get_exit_code_info(code ?? 1)
const semantic = info?.semantic || 'unknown'
const description = info?.description || ''
console.log(`[Worker] exited with code ${code} (${semantic}): ${description}`)
for (const handler of this.exit_handlers) {
handler({ code, signal: signal as NodeJS.Signals | null, semantic, description })
}
})
}

View File

@@ -0,0 +1,103 @@
import { describe, it, expect } from 'bun:test'
import { mkdtempSync, writeFileSync, existsSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { ToolRegistry } from '../../src/tools/ToolRegistry.js'
import { BuiltInToolRegistrar } from '../../src/tools/BuiltInToolRegistrar.js'
import { Scheduler } from '../../src/scheduler/Scheduler.js'
import { MainAgent } from '../../src/agents/main/MainAgent.js'
import { ContextAssembler } from '../../src/context/ContextAssembler.js'
function createRegistry(projectRoot: string): ToolRegistry {
const registry = new ToolRegistry(projectRoot)
new BuiltInToolRegistrar(registry).register_all(projectRoot)
return registry
}
describe('Release critical gates', () => {
it('built-in tool success envelopes use output, not content', async () => {
const projectRoot = mkdtempSync(join(tmpdir(), 'air-tool-envelope-'))
writeFileSync(join(projectRoot, 'sample.txt'), 'hello')
const registry = createRegistry(projectRoot)
const ctx = { session_id: 's', project_id: 'p', project_root: projectRoot, agent_id: 'a', agent_type: 'executor' as const }
for (const [name, args] of [
['fs.stat', { path: 'sample.txt' }],
['project.scan', { root: '.' }],
['cpp.detect', { project_root: projectRoot }],
['doctor.run', { scope: 'all' }],
] as Array<[string, Record<string, unknown>]>) {
const result = await registry.call({ call_id: `call-${name}`, name, arguments: args }, ctx)
expect(result.status).toBe('ok')
expect(result.output).toBeDefined()
expect((result as any).content).toBeUndefined()
}
})
it('shell.run returns a final envelope through call and streaming APIs', async () => {
const projectRoot = mkdtempSync(join(tmpdir(), 'air-shell-'))
const registry = createRegistry(projectRoot)
const ctx = { session_id: 's', project_id: 'p', project_root: projectRoot, agent_id: 'a', agent_type: 'executor' as const }
const final = await registry.call({ call_id: 'shell-call', name: 'shell.run', arguments: { command: 'printf ok' } }, ctx)
expect(final.status).toBe('ok')
expect((final.output as any).exit_code).toBe(0)
expect((final.output as any).stdout).toBe('ok')
expect((final.metadata as any).is_final).toBe(true)
const chunks = []
for await (const chunk of registry.call_streaming({ call_id: 'shell-stream', name: 'shell.run', arguments: { command: 'printf ok' } }, ctx)) {
chunks.push(chunk)
}
expect(chunks.length).toBeGreaterThanOrEqual(2)
expect((chunks.at(-1)!.metadata as any).is_final).toBe(true)
})
it('scheduler does not mark running tasks completed without a worker result', async () => {
const workerManager = {
has_running: () => false,
get_handle_for_task: () => undefined,
get_result_for_task: () => undefined,
}
const scheduler = new Scheduler({ session_id: 's' as any, project_id: 'p' as any, project_root: process.cwd() }, workerManager as any)
scheduler.create_tasks([{ id: 'task-1' as any, type: 'execute', title: 'Task' }])
scheduler.get_graph().update_status('task-1' as any, 'running')
await scheduler.step()
expect(scheduler.get_graph().get_tasks_by_status('running').length).toBe(1)
expect(scheduler.get_graph().get_tasks_by_status('completed').length).toBe(0)
})
it('MainAgent answer mode uses assembled project context', async () => {
const projectRoot = mkdtempSync(join(tmpdir(), 'air-context-'))
writeFileSync(join(projectRoot, 'visible.txt'), 'visible')
const assembler = new ContextAssembler()
const provider = {
async complete_text(messages: Array<{ role: string; content: string }>) {
const joined = messages.map(m => m.content).join('\n')
return { content: joined.includes('visible.txt') || joined.includes('Project') ? 'context seen' : 'missing context' }
}
}
const agent = new MainAgent({
session_id: 's' as any,
project_id: 'p' as any,
provider_manager: provider,
context_assembler: assembler,
project_root: projectRoot,
agent_id: 'main-agent' as any,
})
const result = await agent.handle_user_message('what files are in this project?')
expect(result.action).toBe('answer')
expect(result.response).toBe('context seen')
})
it('destructive requests enter confirmation and rejection returns to idle', async () => {
const agent = new MainAgent({ session_id: 's' as any, project_id: 'p' as any })
const result = await agent.handle_user_message('delete hello.txt')
expect(result.action).toBe('delegate')
expect(agent.state).toBe('CONFIRMING')
await agent.handle_confirmation(false)
expect(agent.state).toBe('IDLE')
})
})

View File

@@ -49,4 +49,11 @@ describe('A5+A4: ToolRegistry permission fixes', () => {
expect(build_match![0]).toContain('context.permission_profile')
expect(build_match![0]).not.toMatch(/permission_profile:\s*undefined/)
})
it('permission denial branches preserve original call_id', () => {
expect(src).toContain("create_error_result(call.call_id, 'user_prompt_required'")
expect(src).toContain("create_error_result(call.call_id, 'permission_denied'")
expect(src).not.toContain("create_error_result('', 'user_prompt_required'")
expect(src).not.toContain("create_error_result('', 'permission_denied'")
})
})

View File

@@ -44,17 +44,13 @@ describe('D3: Worker result envelope', () => {
expect(source).toContain('AgentType')
})
it('wrap_worker_result returns WorkerResult with safe defaults', () => {
// Verify safe defaults for key fields
it('wrap_worker_result maps role results into WorkerResult with safe defaults', () => {
expect(source).toContain("agent_type: (payload.agent_type as AgentType) || 'executor'")
expect(source).toContain("status: (payload.status as WorkerStatus) || 'completed'")
expect(source).toContain("summary: (payload.summary as string) || ''")
expect(source).toContain('changed_files: (payload.changed_files as string[]) || []')
expect(source).toContain('artifacts: (payload.artifacts as any[]) || []')
expect(source).toContain('verification: (payload.verification as any[]) || []')
expect(source).toContain('risks: (payload.risks as any[]) || []')
expect(source).toContain('follow_up_tasks: (payload.follow_up_tasks as any[]) || []')
expect(source).toContain('evidence_refs: (payload.evidence_refs as any[]) || []')
expect(source).toContain("const raw_status = (payload.status as string) || 'completed'")
expect(source).toContain("raw_status === 'fixed' || raw_status === 'pass'")
expect(source).toContain('changes.map((c: any) => String(c.file))')
expect(source).toContain("verification_payload ? [{ command: 'worker verification'")
expect(source).toContain('result: (payload.result as unknown) || payload')
})
it('get_result returns undefined for unknown agent', () => {

View File

@@ -133,13 +133,17 @@ After completing ALL required files, write: DONE`
}
}
// After executing, check if LLM indicated completion
if (text.includes('DONE') || text.includes('TASK_COMPLETE')) {
if (this.is_done_signal(text)) {
if (!allSucceeded) {
messages.push({ role: 'assistant', content: text })
messages.push({ role: 'user', content: 'You signaled DONE, but one or more tool actions failed. Fix the failed actions before signaling DONE.' })
continue
}
await this.runtime.checkpoint('task_completed', { task_id: task_spec.id })
return {
status: 'completed',
changes,
verification: { passed: allSucceeded, output: `${changes.length} files: ${changes.map(c => c.file).join(', ')}` },
verification: { passed: true, output: `${changes.length} files: ${changes.map(c => c.file).join(', ')}` },
evidence_refs: []
}
}
@@ -153,7 +157,7 @@ After completing ALL required files, write: DONE`
})
} else {
// No code blocks, no tool calls — LLM is just talking
if (text.includes('DONE') || text.includes('TASK_COMPLETE')) {
if (this.is_done_signal(text)) {
if (changes.length === 0) {
messages.push({ role: 'assistant', content: text })
messages.push({ role: 'user', content: 'You said DONE but no files were created. Please create the required files first.' })
@@ -189,6 +193,13 @@ After completing ALL required files, write: DONE`
}
}
private is_done_signal(text: string): boolean {
return text
.split(/\r?\n/)
.map(line => line.trim())
.some(line => line === 'DONE' || line === 'TASK_COMPLETE')
}
/**
* Parse ALL actions from LLM response: code blocks and tool calls.
*/
@@ -219,7 +230,7 @@ After completing ALL required files, write: DONE`
filename = extMap[lang] || `${lang}_output.${lang === 'cmake' ? 'txt' : lang}`
}
actions.push({ type: 'code_block', filename, content: match[4].trim() })
actions.push({ type: 'code_block', filename, content: match[4] })
}
// ── Pattern 2: Explicit tool calls ──