Files
AirCoding/packages/runtime/src/workers/WorkerProcess.ts
AirCoding 20bad8ca29 fix(P0): close 15 blockers + add 26 regression tests; fix wiring schema regression
Phase A (security red lines) — CLOSED:
- B8: 3x command injection fixed (execFileSync + args array in CMake/CppBuilder/Cppcheck)
- B6: ToolRegistry permission bypass fixed (real task_scope/profile passed)
- B7: ACTION_BRANCHES this-binding crash fixed (instance method)
- B17: DeveloperLogEncryptor hardcoded 'dev-key' removed (throws if no key)
- B22: CommandRiskAnalyzer 'in' operator bug fixed (includes)
- B1: EventStore.project() transaction handle now passed to all repos
- B2: workspace projection illegal enum fixed (active/merged)
- B4: route_prefix separator unified to '/'
- B5: TaskAttempt column mapping fixed

Other blockers fixed:
- B3: project-level DB schema aligned to db-schema §20 (.air/local, learned_memories)
- B9: cpp.* tools registered through PermissionEngine path
- B11: Scheduler BLOCKED/CANCELLED states added
- B18: CapabilityTrustLevel 5-level enum aligned
- B19: PermissionEngine block/refuse/announce_then_run + grant_scope
- B20: Worker exit code 4 = parent_cancelled
- B24: project_id now randomUUID

Regression fix (introduced by B3 schema refactor):
- wiring.ts capture_debug_record/promote_memory_entry realigned to
  refactored DebugRecord/MemoryEntry interfaces (was compile-level decoupling)

Tests: 128 regression/unit tests pass (22 regression + 3 unit + 3 e2e suites)

Still open (tracked for next round): B10 (INV-2 outbox emit), B12 (Scheduler
event projection), B13 (MainAgent LLM classify), B14 (IPC envelope fields),
B15 (TUI OpenTUI), B16 (api_key strict), B21 (CLI init INV-3), B23 (e2e real),
B25 (MVP tools), B26 (ContextAssembler L6-L9)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:13:27 +08:00

148 lines
3.9 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* WorkerProcess - Owns NDJSON pipe for a Bun child process
*
* Implements DD §8.1. stdout=protocol, stderr=fatal/log; exit-code table 05.
*
* @module packages/runtime/src/workers/WorkerProcess
*/
import type { ChildProcess } from 'child_process'
import { WorkerProtocol, type WorkerMessage, type WorkerMessageType, type WorkerMessageDirection } from './WorkerProtocol.js'
export type WorkerExitCode =
| 0 // Normal exit
| 1 // Error (unrecoverable)
| 2 // Protocol error
| 3 // Permission denied
| 4 // Parent cancelled
| 5 // Timeout
interface ExitCodeInfo {
semantic: string
description: string
}
const EXIT_CODE_TABLE: Record<WorkerExitCode, ExitCodeInfo> = {
0: { semantic: 'normal', description: 'Worker completed successfully' },
1: { semantic: 'error', description: 'Unrecoverable error occurred' },
2: { semantic: 'protocol_error', description: 'Protocol violation or deserialization failure' },
3: { semantic: 'permission_denied', description: 'Worker denied permission for operation' },
4: { semantic: 'parent_cancelled', description: 'Parent process cancelled this worker' },
5: { semantic: 'timeout', description: 'Worker exceeded time limit' }
}
export class WorkerProcess {
private proc: ChildProcess | null = null
private protocol: WorkerProtocol
private message_handlers: Map<string, (msg: WorkerMessage) => void> = new Map()
private buffer: string = ''
constructor() {
this.protocol = new WorkerProtocol()
}
/**
* Set the child process.
*/
set_process(proc: ChildProcess): void {
this.proc = proc
this.setup_streams()
}
/**
* Send a message to the worker process.
*/
send(message: WorkerMessage): void {
if (!this.proc?.stdin?.writable) {
throw new Error('Worker process stdin is not writable')
}
const line = this.protocol.encode(message)
this.proc.stdin.write(line)
}
/**
* Register a message handler.
*/
on_message(type: WorkerMessageType, handler: (msg: WorkerMessage) => void): void {
this.message_handlers.set(type, handler)
}
/**
* Get exit code info.
*/
get_exit_code_info(code: number): ExitCodeInfo | undefined {
return EXIT_CODE_TABLE[code as WorkerExitCode]
}
/**
* Check if process is alive.
*/
is_alive(): boolean {
if (!this.proc) return false
return this.proc.exitCode === null
}
/**
* Kill the worker process.
*/
kill(signal: NodeJS.Signals = 'SIGTERM'): boolean {
return this.proc?.kill(signal) || false
}
/**
* Get the process ID.
*/
get_pid(): number | undefined {
return this.proc?.pid
}
// ============================================================================
// Private
// ============================================================================
private setup_streams(): void {
if (!this.proc) return
// stdout = protocol channel
if (this.proc.stdout) {
this.proc.stdout.on('data', (data: Buffer) => {
this.buffer += data.toString()
this.process_buffer()
})
}
// stderr = log/fatal
if (this.proc.stderr) {
this.proc.stderr.on('data', (data: Buffer) => {
const message = data.toString().trim()
if (message) {
console.error('[Worker stderr]', message)
}
})
}
// 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 || ''}`)
})
}
private process_buffer(): void {
const lines = this.buffer.split('\n')
this.buffer = lines.pop() || ''
for (const line of lines) {
const message = this.protocol.decode(line)
if (!message) continue
// Route to handler
const handler = this.message_handlers.get(message.type)
if (handler) {
handler(message)
}
}
}
}