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')
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
103
packages/runtime/test/regression/release-critical-gates.test.ts
Executable file
103
packages/runtime/test/regression/release-critical-gates.test.ts
Executable 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')
|
||||
})
|
||||
})
|
||||
@@ -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'")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
Reference in New Issue
Block a user