- 打通 Worker → WorkerManager → Provider 的 tools 传递链路,ProviderManager/adapter 返回结构化 tool_calls 给 WorkerRuntime - OpenAI-compatible/Anthropic adapter 发送工具 schema,并解析 provider 返回的 tool_calls/tool_use;OpenAI 工具名使用 fs.write ↔ fs__write 双向映射 - 修复独立复审发现的 OpenAI 协议隐患:assistant tool_use blocks 必须转换为 assistant.tool_calls,后续 role=tool 消息的 tool_call_id 必须匹配前一轮 tool_calls[].id;不再把 tool_use JSON 字符串化为普通文本 - ExecutorRole 优先消费原生 tool_calls,回灌 canonical tool_result block;移除 fs.write(...)/shell.run(...) 函数调用正则解析,只保留严格 JSON tool_call fallback 与 filename code block 兼容 - DONE 前执行 verification-before-completion:任务要求 build/compile/run/test/编译/ 运行/测试时必须实际 shell.run 验证,失败不 checkpoint、不返回 completed - fs.write 覆盖已有文件也强制 read-before-write,补齐 Claude Code 文件状态纪律 - 新增 packages/workers/test/executor-role.test.ts 行为测试:原生 tool_calls 执行、 verification 失败不得 completed 真实验收: - TSC=0 - bun test packages/workers/test/executor-role.test.ts: 2 pass / 0 fail - OpenAI converter 探针确认 assistant.tool_calls 与 role=tool 的 tool_call_id 匹配 - 真实 GLM Worker C++ 编译运行任务通过,worker verification 记录实际命令: c++ hello.cpp -o /tmp/aircoding-verify && /tmp/aircoding-verify Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
459 lines
15 KiB
TypeScript
Executable File
459 lines
15 KiB
TypeScript
Executable File
/**
|
|
* WorkerManager - Spawns and manages worker child processes
|
|
*
|
|
* Implements DD §8.1. spawn (Bun child process + handshake), cancel.
|
|
* INV-1: WorkerManager never writes agents.status directly.
|
|
*
|
|
* @module packages/runtime/src/workers/WorkerManager
|
|
*/
|
|
|
|
import { spawn } from 'child_process'
|
|
import { existsSync } from 'fs'
|
|
import type { ChildProcess } from 'child_process'
|
|
import { WorkerProcess, type WorkerExitCode } from './WorkerProcess.js'
|
|
import { WorkerProtocol, type WorkerMessage, type WorkerMessageType } from './WorkerProtocol.js'
|
|
import type { WorkerResult, WorkerStatus, AgentType } from '@aircoding/contracts'
|
|
import type { ToolRegistry } from '../tools/ToolRegistry.js'
|
|
import type { ProviderManager } from '@aircoding/llm'
|
|
|
|
export interface WorkerConfig {
|
|
entrypoint: string // Path to worker main.ts
|
|
agent_id: string
|
|
session_id: string
|
|
project_root: string
|
|
timeout_ms?: number
|
|
env?: Record<string, string>
|
|
task_type?: string // DD §8.3: execute/review/debug/compact/mine_experience
|
|
task_spec?: Record<string, unknown> // DD §9: TaskSpec payload for worker
|
|
}
|
|
|
|
export interface WorkerHandle {
|
|
worker_id: string
|
|
process: WorkerProcess
|
|
config: WorkerConfig
|
|
state: 'starting' | 'ready' | 'running' | 'completed' | 'failed' | 'error' | 'cancelled'
|
|
started_at: string
|
|
completed_at?: string
|
|
result?: WorkerResult<unknown>
|
|
}
|
|
|
|
export class WorkerManager {
|
|
private protocol: WorkerProtocol
|
|
private workers: Map<string, WorkerHandle> = new Map()
|
|
private tool_registry?: ToolRegistry
|
|
private provider_manager?: ProviderManager
|
|
private execution_context?: { session_id: string; project_id: string; project_root: string }
|
|
|
|
constructor(tool_registry?: ToolRegistry, provider_manager?: ProviderManager) {
|
|
this.protocol = new WorkerProtocol()
|
|
this.tool_registry = tool_registry
|
|
this.provider_manager = provider_manager
|
|
}
|
|
|
|
/**
|
|
* Set execution context (session_id, project_id, project_root).
|
|
* Required for tool/LLM calls from workers.
|
|
*/
|
|
set_context(ctx: { session_id: string; project_id: string; project_root: string }): void {
|
|
this.execution_context = ctx
|
|
}
|
|
|
|
/**
|
|
* Set tool registry (can be injected after construction).
|
|
*/
|
|
set_tool_registry(registry: ToolRegistry): void {
|
|
this.tool_registry = registry
|
|
}
|
|
|
|
/**
|
|
* Set provider manager (can be injected after construction).
|
|
*/
|
|
set_provider_manager(pm: ProviderManager): void {
|
|
this.provider_manager = pm
|
|
}
|
|
|
|
/**
|
|
* Spawn a worker child process and perform handshake.
|
|
* INV-1: worker.ready handshake is a live signal, not a status write.
|
|
*/
|
|
async spawn(config: WorkerConfig): Promise<WorkerHandle> {
|
|
const proc = new WorkerProcess()
|
|
const handle: WorkerHandle = {
|
|
worker_id: config.agent_id,
|
|
process: proc,
|
|
config,
|
|
state: 'starting',
|
|
started_at: new Date().toISOString()
|
|
}
|
|
|
|
// Spawn worker process using Bun
|
|
// Worker must run from AirCoding repo root so Bun can resolve modules
|
|
const repo_root = process.env.AIRCODING_REPO_ROOT || config.project_root
|
|
const bun_path = this.find_bun()
|
|
const entrypoint = config.entrypoint.startsWith('/') ? config.entrypoint
|
|
: `${repo_root}/${config.entrypoint.replace(/^\.\//, '')}`
|
|
|
|
const child = spawn(bun_path, ['run', entrypoint], {
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
env: {
|
|
...process.env,
|
|
...config.env,
|
|
AIRCODING_AGENT_ID: config.agent_id,
|
|
AIRCODING_SESSION_ID: config.session_id,
|
|
AIRCODING_PROJECT_ROOT: config.project_root
|
|
},
|
|
cwd: repo_root
|
|
})
|
|
|
|
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)
|
|
|
|
// Wait for handshake: worker.ready
|
|
await this.wait_for_handshake(proc, config)
|
|
|
|
// Validate protocol version and dispatch task
|
|
const ready_msg = this.send_and_wait(proc, 'agent.start', {
|
|
protocol_version: this.protocol.get_version(),
|
|
agent_id: config.agent_id,
|
|
session_id: config.session_id,
|
|
project_root: config.project_root,
|
|
task_type: config.task_type || 'execute',
|
|
task_spec: config.task_spec || { id: `${config.agent_id}_task`, title: 'Execute task', description: '' }
|
|
})
|
|
|
|
handle.state = 'ready'
|
|
this.workers.set(config.agent_id, handle)
|
|
|
|
// Set up timeout
|
|
if (config.timeout_ms) {
|
|
setTimeout(() => this.cancel(config.agent_id, 'timeout'), config.timeout_ms)
|
|
}
|
|
|
|
return handle
|
|
}
|
|
|
|
/**
|
|
* Cancel a worker.
|
|
*/
|
|
async cancel(agent_id: string, reason: string): Promise<void> {
|
|
const handle = this.workers.get(agent_id)
|
|
if (!handle) return
|
|
|
|
const msg = this.protocol.create_message('agent.cancel', { reason }, 'parent_to_worker')
|
|
handle.process.send(msg)
|
|
handle.state = 'cancelled'
|
|
|
|
// Wait briefly then force kill
|
|
setTimeout(() => {
|
|
if (handle.process.is_alive()) {
|
|
handle.process.kill('SIGKILL')
|
|
}
|
|
}, 5000)
|
|
}
|
|
|
|
/**
|
|
* Set up IPC message handlers for a worker process.
|
|
* Handles tool.call and llm.request forwarded from worker to parent.
|
|
*/
|
|
private setup_worker_handlers(proc: WorkerProcess, agent_id: string): void {
|
|
// Handle tool.call from worker → execute via ToolRegistry
|
|
proc.on_message('tool.call', async (msg) => {
|
|
const call_id = msg.payload.call_id as string
|
|
const tool_name = msg.payload.name as string
|
|
const tool_args = (msg.payload.arguments || {}) as Record<string, unknown>
|
|
|
|
try {
|
|
if (!this.tool_registry) {
|
|
this.send_to_worker(agent_id, 'tool.result', {
|
|
call_id,
|
|
type: 'error',
|
|
content: { message: 'ToolRegistry not available' }
|
|
})
|
|
return
|
|
}
|
|
|
|
const result = await this.tool_registry.call(
|
|
{ call_id, name: tool_name, arguments: tool_args },
|
|
{
|
|
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,
|
|
agent_type: 'executor',
|
|
}
|
|
)
|
|
|
|
this.send_to_worker(agent_id, 'tool.result', {
|
|
call_id,
|
|
type: result.status === 'ok' ? 'text' : 'error',
|
|
content: result.output || result.error || {}
|
|
})
|
|
} catch (e: any) {
|
|
console.error('[WM] tool.call error:', e.message)
|
|
this.send_to_worker(agent_id, 'tool.result', {
|
|
call_id,
|
|
type: 'error',
|
|
content: { message: e.message || 'Tool execution failed' }
|
|
})
|
|
}
|
|
})
|
|
|
|
// Handle llm.request from worker → execute via ProviderManager
|
|
proc.on_message('llm.request', async (msg) => {
|
|
const call_id = msg.payload.call_id as string
|
|
try {
|
|
if (!this.provider_manager) {
|
|
this.send_to_worker(agent_id, 'llm.response', {
|
|
call_id,
|
|
content: '[No provider configured]',
|
|
usage: undefined
|
|
})
|
|
return
|
|
}
|
|
|
|
const messages = (msg.payload.messages || []) as Array<{ role: string; content: unknown }>
|
|
const response = await this.provider_manager.complete_text(messages, {
|
|
model: msg.payload.model as string,
|
|
max_tokens: msg.payload.max_tokens as number,
|
|
temperature: msg.payload.temperature as number,
|
|
tools: (msg.payload.tools as unknown[]) || this.tool_registry?.list?.() || []
|
|
})
|
|
|
|
this.send_to_worker(agent_id, 'llm.response', {
|
|
call_id,
|
|
content: response.content,
|
|
usage: response.usage,
|
|
tool_calls: response.tool_calls
|
|
})
|
|
} catch (e: any) {
|
|
this.send_to_worker(agent_id, 'llm.response', {
|
|
call_id,
|
|
content: `[LLM Error: ${e.message}]`,
|
|
usage: undefined
|
|
})
|
|
}
|
|
})
|
|
|
|
// Handle worker.result → update handle
|
|
proc.on_message('worker.result', (msg) => {
|
|
const handle = this.workers.get(agent_id)
|
|
if (handle) {
|
|
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()
|
|
}
|
|
})
|
|
|
|
// Handle worker.checkpoint
|
|
proc.on_message('worker.checkpoint', (msg) => {
|
|
const handle = this.workers.get(agent_id)
|
|
if (handle) {
|
|
handle.state = 'running'
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Send a message back to a specific worker.
|
|
*/
|
|
private send_to_worker(agent_id: string, type: string, payload: Record<string, unknown>): void {
|
|
const handle = this.workers.get(agent_id)
|
|
if (!handle) return
|
|
const msg = this.protocol.create_message(type as WorkerMessageType, payload, 'parent_to_worker', {
|
|
session_id: this.execution_context?.session_id,
|
|
agent_id
|
|
})
|
|
handle.process.send(msg)
|
|
}
|
|
|
|
/**
|
|
* Send a message to a worker.
|
|
*/
|
|
send(agent_id: string, type: WorkerMessageType, payload: Record<string, unknown>): void {
|
|
const handle = this.workers.get(agent_id)
|
|
if (!handle) throw new Error(`Worker not found: ${agent_id}`)
|
|
|
|
const msg = this.protocol.create_message(type, payload, 'parent_to_worker')
|
|
handle.process.send(msg)
|
|
}
|
|
|
|
/**
|
|
* Get a worker handle.
|
|
*/
|
|
get(agent_id: string): WorkerHandle | undefined {
|
|
return this.workers.get(agent_id)
|
|
}
|
|
|
|
/**
|
|
* List all workers.
|
|
*/
|
|
list(): WorkerHandle[] {
|
|
return Array.from(this.workers.values())
|
|
}
|
|
|
|
/**
|
|
* List workers by state.
|
|
*/
|
|
list_by_state(state: WorkerHandle['state']): WorkerHandle[] {
|
|
return this.list().filter(w => w.state === state)
|
|
}
|
|
|
|
/**
|
|
* Check if any workers are running.
|
|
*/
|
|
has_running(): boolean {
|
|
return this.list().some(w => w.state === 'running' || w.state === 'ready' || w.state === 'starting')
|
|
}
|
|
|
|
/**
|
|
* Get the stored WorkerResult for an agent.
|
|
*/
|
|
get_result(agent_id: string): WorkerResult<unknown> | undefined {
|
|
const handle = this.workers.get(agent_id)
|
|
if (!handle) return undefined
|
|
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) || (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: status as WorkerStatus,
|
|
summary,
|
|
changed_files,
|
|
diff_ref: (payload.diff_ref as string | undefined) || undefined,
|
|
artifacts: (payload.artifacts 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) || payload,
|
|
}
|
|
}
|
|
|
|
private async wait_for_handshake(proc: WorkerProcess, config: WorkerConfig): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
reject(new Error(`Worker handshake timeout: ${config.agent_id}`))
|
|
}, 30000)
|
|
|
|
proc.on_message('worker.ready', (msg) => {
|
|
clearTimeout(timeout)
|
|
const version = msg.payload.protocol_version as number
|
|
const check = this.protocol.check_version(version)
|
|
if (!check.compatible) {
|
|
reject(new Error(check.error))
|
|
return
|
|
}
|
|
resolve()
|
|
})
|
|
|
|
// Also handle worker.error
|
|
proc.on_message('worker.error', (msg) => {
|
|
clearTimeout(timeout)
|
|
reject(new Error(`Worker error during handshake: ${msg.payload.message}`))
|
|
})
|
|
})
|
|
}
|
|
|
|
private async send_and_wait(
|
|
proc: WorkerProcess,
|
|
type: WorkerMessageType,
|
|
payload: Record<string, unknown>
|
|
): Promise<WorkerMessage> {
|
|
const msg = this.protocol.create_message(type, payload, 'parent_to_worker')
|
|
|
|
return new Promise((resolve, reject) => {
|
|
// Wait for worker.send callback (worker acknowledges agent.start)
|
|
// For now just send and resolve after a short delay
|
|
proc.send(msg)
|
|
setTimeout(() => resolve(msg), 100)
|
|
})
|
|
}
|
|
|
|
private find_bun(): string {
|
|
// Try common paths first (no shell, no string interpolation)
|
|
const candidates = [
|
|
process.env.BUN_INSTALL ? `${process.env.BUN_INSTALL}/bin/bun` : null,
|
|
`${process.env.HOME || '/root'}/.bun/bin/bun`,
|
|
'/usr/local/bin/bun',
|
|
'/usr/bin/bun',
|
|
].filter((p): p is string => Boolean(p))
|
|
|
|
for (const candidate of candidates) {
|
|
if (existsSync(candidate)) return candidate
|
|
}
|
|
return 'bun' // PATH fallback
|
|
}
|
|
}
|