feat(round2+round3): 完整实现 A/B/C/D 主线 + round3-F/H 修复

Round2 主线:
- A: 事件落库地基 (RuntimeApp EventStore 单例 + 14 repo wiring)
- B: 执行体对齐 (read-before-edit, verification-before-completion)
- C: 界面对齐 (@opentui/solid, 删除 runtime 依赖)
- D: 经验闭环 (ExperienceMiner, DebuggerRole, CompactorRole)

Round2 补充修复:
- fail-on-missing 反作弊门禁
- projection-store-apply.test.ts 补写
- 3个空壳测试转行为 (evidence-store, recovery-impl, knowledge-store)
- ask 项目根支持 AIRCODING_PROJECT_ROOT
- Worker 事件契约修复 (task.attempt.started → checkpoint)

Round3-F: cpp 工具切换
- 删除 BuiltInToolRegistrar cpp.* 闭包
- 接入 toolchain-cpp 真实 CppToolRegistrar
- canonical envelope {status/output/metadata}
- ExecutorRole system prompt 对齐新工具名

Round3-H: Doctor 5 类报告
- toolchain (cmake/ninja/cppcheck/clangd/g++)
- display (X11/Wayland + ImageMagick)
- network (internet connectivity)
- provider (api_key/base_url/model/connectivity)

Secret 脱敏:
- 状态交接.md: sk- → \${OPENAI_API_KEY}
- .gitignore: 添加 .air/ .claude/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AirCoding
2026-06-09 16:13:16 +08:00
parent e383d5f6a7
commit 5e282a39b4
44 changed files with 2905 additions and 1343 deletions

View File

@@ -111,7 +111,7 @@ export class WorkerRuntime {
* Emit an event to the parent.
*/
emit(type: string, payload: Record<string, unknown>): void {
this.send_message('event', { type, ...payload })
this.send_message('event', { event_type: type, ...payload })
}
/**

View File

@@ -8,6 +8,11 @@
import { WorkerRuntime } from '../WorkerRuntime.js'
const SUMMARY_PREFIX = `This is a compacted summary of earlier context. Treat it as reference only.
The latest user message and any newer runtime events after this summary are the source of truth.
If this summary conflicts with newer instructions, follow the newer instructions.
Preserve active tasks, unresolved questions, architectural constraints, verification status, and remaining work.`
export interface CompactorResult {
status: 'compacted' | 'skipped' | 'blocked'
summary_content: string
@@ -22,7 +27,15 @@ export class CompactorRole {
this.runtime = runtime
}
async run(compact_spec: { task_id: string; current_tokens: number; threshold: number }): Promise<CompactorResult> {
async run(compact_spec: {
task_id?: string
current_tokens?: number
threshold?: number
target_budget_tokens?: number
range_start_message_id?: string
range_end_message_id?: string
source_content?: string
}): Promise<CompactorResult> {
const result: CompactorResult = {
status: 'skipped',
summary_content: '',
@@ -30,48 +43,105 @@ export class CompactorRole {
compacted_layers: []
}
try {
this.runtime.emit('compaction.started', { task_id: compact_spec.task_id })
const task_id = compact_spec.task_id || 'compact_task'
const current_tokens = compact_spec.current_tokens ?? 0
const threshold = compact_spec.threshold ?? compact_spec.target_budget_tokens ?? 80000
const range_start_message_id = compact_spec.range_start_message_id || ''
const range_end_message_id = compact_spec.range_end_message_id || ''
// Check if compaction is needed
if (compact_spec.current_tokens < compact_spec.threshold) {
result.status = 'skipped'
result.summary_content = `Tokens (${compact_spec.current_tokens}) below threshold (${compact_spec.threshold}) — no compaction needed`
try {
this.runtime.emit('context.compaction.started', {
event_id: `evt_compaction_started_${crypto.randomUUID()}`,
task_id,
agent_id: process.env.AIRCODING_AGENT_ID || 'compactor',
range_start_message_id,
range_end_message_id,
})
if (current_tokens > 0 && current_tokens < threshold) {
result.summary_content = `Tokens (${current_tokens}) below threshold (${threshold}); no compaction needed.`
return result
}
// Use LLM to generate summary of the conversation
const tokens_to_free = compact_spec.current_tokens - Math.floor(compact_spec.threshold * 0.6)
const compaction_messages = [
{ role: 'system', content: 'Summarize the key facts, decisions, and code changes from the conversation history. Keep it concise but complete. Include file paths, function names, and architectural decisions.' },
{ role: 'user', content: `Compaction requested: ${compact_spec.current_tokens} tokens in context, threshold is ${compact_spec.threshold}. Generate a compact summary to free approximately ${tokens_to_free} tokens.` }
]
const token_estimate_before = current_tokens || threshold
const target_after = Math.max(1, Math.floor(threshold * 0.6))
const source_content = compact_spec.source_content || `Current token estimate: ${token_estimate_before}; target budget: ${threshold}.`
try {
const summary = await this.runtime.call_llm({
messages: compaction_messages,
max_tokens: 2048,
temperature: 0.2
})
const summary = await this.build_summary(source_content, token_estimate_before, threshold)
const summary_id = `summary_${crypto.randomUUID()}`
const token_estimate_after = Math.min(target_after, Math.max(1, Math.floor(summary.length / 4)))
result.summary_content = summary.content || '# Compaction Summary\n\nContext has been compacted to reduce token usage.'
result.tokens_freed = tokens_to_free
result.compacted_layers = ['conversation', 'tool_output']
result.status = 'compacted'
} catch {
result.summary_content = '# Compaction Summary\n\nSummary generation failed — using basic compaction.'
result.tokens_freed = compact_spec.current_tokens - Math.floor(compact_spec.current_tokens * 0.6)
result.compacted_layers = ['conversation']
result.status = 'compacted'
}
result.summary_content = summary
result.tokens_freed = Math.max(0, token_estimate_before - token_estimate_after)
result.compacted_layers = ['conversation', 'tool_output', 'images']
result.status = 'compacted'
this.runtime.checkpoint('compaction_completed', { task_id: compact_spec.task_id })
this.runtime.emit('summary.created', {
event_id: `evt_${summary_id}`,
summary_id,
type: 'compaction',
range_start_message_id,
range_end_message_id,
content_json: {
prefix: SUMMARY_PREFIX,
summary,
active_task: task_id,
remaining_work: [],
resolved_questions: [],
pending_questions: [],
},
metadata: {
token_estimate_before,
token_estimate_after,
compacted_layers: result.compacted_layers,
},
})
this.runtime.emit('context.compaction.completed', {
event_id: `evt_compaction_completed_${crypto.randomUUID()}`,
task_id,
agent_id: process.env.AIRCODING_AGENT_ID || 'compactor',
summary_id,
range_start_message_id,
range_end_message_id,
token_estimate_before,
token_estimate_after,
})
this.runtime.checkpoint('compaction_completed', { task_id, summary_id, tokens_freed: result.tokens_freed })
return result
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
result.status = 'blocked'
result.summary_content = error instanceof Error ? error.message : String(error)
result.summary_content = message
this.runtime.emit('context.compaction.failed', {
event_id: `evt_compaction_failed_${crypto.randomUUID()}`,
task_id,
agent_id: process.env.AIRCODING_AGENT_ID || 'compactor',
range_start_message_id,
range_end_message_id,
error: { message },
evidence_refs: [],
metadata: {},
})
return result
}
}
}
private async build_summary(source_content: string, current_tokens: number, threshold: number): Promise<string> {
try {
const response = await this.runtime.call_llm({
messages: [
{ role: 'system', content: `${SUMMARY_PREFIX}\n\nReturn a structured summary with sections: Active task, Key facts, Decisions, Changed files, Verification, Remaining work, Pending questions.` },
{ role: 'user', content: `Compact this context from ~${current_tokens} tokens toward ${threshold}.\n\n${source_content}` },
],
max_tokens: 2048,
temperature: 0.2,
})
return `${SUMMARY_PREFIX}\n\n${(response.content || '').trim() || 'No detailed summary was produced.'}`
} catch {
return `${SUMMARY_PREFIX}\n\nActive task: context compaction.\nKey facts: source context was too large or summarizer was unavailable.\nRemaining work: rehydrate from durable events and latest user message before continuing.`
}
}
}

View File

@@ -8,6 +8,38 @@
import { WorkerRuntime } from '../WorkerRuntime.js'
type FailoverReason =
| 'auth'
| 'auth_permanent'
| 'billing'
| 'rate_limit'
| 'overloaded'
| 'server_error'
| 'timeout'
| 'context_overflow'
| 'payload_too_large'
| 'image_too_large'
| 'model_not_found'
| 'provider_policy_blocked'
| 'content_policy_blocked'
| 'format_error'
| 'invalid_encrypted_content'
| 'multimodal_tool_content_unsupported'
| 'thinking_signature'
| 'long_context_tier'
| 'oauth_long_context_beta_forbidden'
| 'llama_cpp_grammar_pattern'
| 'unknown'
interface ClassifiedError {
reason: FailoverReason
message: string
retryable: boolean
should_compress: boolean
should_rotate_credential: boolean
should_fallback: boolean
}
export interface DebuggerResult {
status: 'fixed' | 'cannot_reproduce' | 'blocked' | 'escalated'
root_cause: string
@@ -23,7 +55,7 @@ export class DebuggerRole {
this.runtime = runtime
}
async run(debug_spec: { task_id: string; error_report: string; affected_files: string[] }): Promise<DebuggerResult> {
async run(debug_spec: { task_id?: string; error_report?: string; affected_files?: string[]; verification_refs?: string[] }): Promise<DebuggerResult> {
const result: DebuggerResult = {
status: 'cannot_reproduce',
root_cause: '',
@@ -32,54 +64,56 @@ export class DebuggerRole {
}
try {
this.runtime.emit('debug.started', { task_id: debug_spec.task_id })
const task_id = debug_spec.task_id || 'unknown_task'
const error_report = debug_spec.error_report || ''
const affected_files = debug_spec.affected_files ?? []
const classified = this.classify_error(error_report)
// Step 1: Gather evidence — read affected files
result.diagnostic_chain.push('1. Gathering evidence from affected files')
for (const file of debug_spec.affected_files) {
result.diagnostic_chain.push(`1. Classified failure as ${classified.reason}`)
result.diagnostic_chain.push(` retryable=${classified.retryable} compress=${classified.should_compress} rotate_credential=${classified.should_rotate_credential} fallback=${classified.should_fallback}`)
result.diagnostic_chain.push('2. Gathering evidence from affected files')
for (const file of affected_files) {
try {
await this.runtime.call_tool('fs.read', { path: file })
result.evidence_refs.push(`file:${file}`)
const read = await this.runtime.call_tool('fs.read', { path: file })
if (read.type === 'error') {
result.diagnostic_chain.push(` Failed to read: ${file}`)
} else {
result.evidence_refs.push(`file:${file}`)
}
} catch {
result.diagnostic_chain.push(` Failed to read: ${file}`)
}
}
// Step 2: Analyze error signatures using LLM
result.diagnostic_chain.push('2. Analyzing error signatures')
const messages = [
{ role: 'system', content: 'You are a debugging expert. Analyze the error report and suggest a fix.' },
{ role: 'user', content: `Error report:\n${debug_spec.error_report}\n\nAffected files: ${debug_spec.affected_files.join(', ')}\n\nDiagnose the root cause and propose a fix. Be specific about which file and what change.` }
]
result.diagnostic_chain.push('3. Analyzing root cause')
try {
const analysis = await this.runtime.call_llm({ messages, max_tokens: 2048, temperature: 0.3 })
result.root_cause = analysis.content || 'Unable to determine root cause'
result.diagnostic_chain.push(` Analysis: ${result.root_cause.slice(0, 100)}...`)
const analysis = await this.runtime.call_llm({
messages: [
{ role: 'system', content: 'You are a diagnostic agent. Identify the likely root cause and recovery path. Do not claim a fix was applied unless a tool edit actually succeeded.' },
{ role: 'user', content: `Classified error: ${JSON.stringify(classified)}\n\nError report:\n${error_report}\n\nAffected files: ${affected_files.join(', ') || '(none)'}` },
],
max_tokens: 2048,
temperature: 0.2,
})
result.root_cause = (analysis.content || '').trim() || this.default_root_cause(classified)
} catch {
result.root_cause = 'LLM analysis unavailable — manual diagnosis required'
result.root_cause = this.default_root_cause(classified)
}
// Step 3: Attempt fix
result.diagnostic_chain.push('3. Attempting fix')
if (result.root_cause.includes('fix:') || result.root_cause.includes('change:') || result.root_cause.includes('Fix:')) {
const fix_match = result.root_cause.match(/fix:\s*([^\n]+)/i) || result.root_cause.match(/change:\s*([^\n]+)/i)
if (fix_match && debug_spec.affected_files.length > 0) {
result.fix_applied = { file: debug_spec.affected_files[0], change: fix_match[1] }
result.status = 'fixed'
result.diagnostic_chain.push(' Fix applied to ' + debug_spec.affected_files[0])
}
}
result.status = this.status_for(classified)
const debug_record_id = `debug_${crypto.randomUUID()}`
this.runtime.emit('debug.record.created', {
event_id: `evt_${debug_record_id}`,
debug_record_id,
task_id,
failure_signature: classified.reason,
summary: result.root_cause.slice(0, 1000),
evidence_refs: result.evidence_refs,
verification_refs: debug_spec.verification_refs ?? [],
})
// Step 4: Verify fix
if (result.status === 'fixed') {
result.diagnostic_chain.push('4. Verification')
try {
await this.runtime.call_tool('shell.run', { command: 'echo "Verification passed — fix applied"', timeout: 30000 })
} catch { /* verification skipped */ }
}
this.runtime.checkpoint('debug_completed', { task_id: debug_spec.task_id })
this.runtime.checkpoint('debug_completed', { task_id, reason: classified.reason, status: result.status })
return result
} catch (error) {
@@ -88,4 +122,53 @@ export class DebuggerRole {
return result
}
}
}
private classify_error(report: string): ClassifiedError {
const text = report.toLowerCase()
const reason: FailoverReason = this.reason_for(text)
return {
reason,
message: report,
retryable: !['auth_permanent', 'billing', 'model_not_found', 'provider_policy_blocked', 'content_policy_blocked', 'format_error', 'invalid_encrypted_content'].includes(reason),
should_compress: reason === 'context_overflow' || reason === 'payload_too_large' || reason === 'image_too_large',
should_rotate_credential: reason === 'auth' || reason === 'auth_permanent',
should_fallback: ['rate_limit', 'overloaded', 'server_error', 'timeout', 'model_not_found', 'long_context_tier', 'oauth_long_context_beta_forbidden'].includes(reason),
}
}
private reason_for(text: string): FailoverReason {
if (/context|token|maximum context|too many tokens|context_length/.test(text)) return 'context_overflow'
if (/payload too large|request too large|413/.test(text)) return 'payload_too_large'
if (/image.*too large|vision.*size/.test(text)) return 'image_too_large'
if (/rate limit|too many requests|429/.test(text)) return 'rate_limit'
if (/overloaded|capacity|529/.test(text)) return 'overloaded'
if (/timeout|timed out|etimedout|504/.test(text)) return 'timeout'
if (/500|502|503|server error|bad gateway|service unavailable/.test(text)) return 'server_error'
if (/invalid api key|unauthorized|401|forbidden|403|auth/.test(text)) return /invalid api key|revoked|expired/.test(text) ? 'auth_permanent' : 'auth'
if (/billing|quota|insufficient credits|payment/.test(text)) return 'billing'
if (/model.*not found|unknown model|404/.test(text)) return 'model_not_found'
if (/policy|safety|blocked by provider/.test(text)) return 'provider_policy_blocked'
if (/content policy|unsafe content/.test(text)) return 'content_policy_blocked'
if (/json|schema|format|parse/.test(text)) return 'format_error'
if (/encrypted content/.test(text)) return 'invalid_encrypted_content'
if (/multimodal.*tool|tool.*image/.test(text)) return 'multimodal_tool_content_unsupported'
if (/thinking.*signature|signature mismatch/.test(text)) return 'thinking_signature'
if (/long context/.test(text)) return 'long_context_tier'
if (/oauth.*long context|beta.*forbidden/.test(text)) return 'oauth_long_context_beta_forbidden'
if (/grammar|llama.cpp|llama_cpp/.test(text)) return 'llama_cpp_grammar_pattern'
return 'unknown'
}
private default_root_cause(classified: ClassifiedError): string {
if (classified.should_compress) return `Likely ${classified.reason}; compress context or reduce payload before retry.`
if (classified.should_rotate_credential) return `Likely ${classified.reason}; credential or authorization requires attention before retry.`
if (classified.should_fallback) return `Likely ${classified.reason}; retry with backoff or fallback provider/model.`
return `Failure classified as ${classified.reason}; manual diagnosis required.`
}
private status_for(classified: ClassifiedError): DebuggerResult['status'] {
if (classified.should_rotate_credential || classified.reason === 'billing' || classified.reason === 'content_policy_blocked') return 'escalated'
if (classified.retryable || classified.should_compress || classified.should_fallback) return 'cannot_reproduce'
return 'blocked'
}
}

View File

@@ -30,7 +30,7 @@ export class ExecutorRole {
}
async run(task_spec: { id: string; title: string; description: string; acceptance_criteria: string[] }): Promise<ExecutorResult> {
this.runtime.emit('task.attempt.started', { task_id: task_spec.id })
this.runtime.checkpoint('task_attempt_started', { task_id: task_spec.id })
const model = (task_spec as any).model || process.env.AIRCODING_MODEL || 'glm-5.1'
const projectRoot = process.env.AIRCODING_PROJECT_ROOT || '.'
@@ -41,7 +41,10 @@ export class ExecutorRole {
role: 'system',
content: `You are an AI coding assistant. Complete coding tasks by writing code files.
Use structured tool calls whenever possible. Available tools include fs.read, fs.write, fs.edit, fs.list, shell.run, cpp.detect, cpp.build, and cpp.test.
Use structured tool calls whenever possible. Available tools include:
- fs.read, fs.write, fs.edit, fs.list — filesystem operations
- shell.run — shell command execution
- cpp.detect, cpp.configure, cpp.build, cpp.test, cpp.cppcheck, cpp.clangd — C++ toolchain
If native tools are unavailable, output strict JSON tool calls only in this form:
@@ -201,10 +204,6 @@ After all required files are written and required verification has passed, write
}
} catch (error) {
this.runtime.emit('task.blocked', {
task_id: task_spec.id,
error: error instanceof Error ? error.message : String(error)
})
return { status: 'blocked', error: error instanceof Error ? error.message : String(error) }
}
}

View File

@@ -8,6 +8,8 @@
import { WorkerRuntime } from '../WorkerRuntime.js'
const MEMORY_TYPES = new Set(['project_rule', 'toolchain_rule', 'skill_update', 'debug_experience'])
export interface ExperienceMinerResult {
status: 'completed' | 'no_patterns' | 'blocked'
entries: Array<{
@@ -26,7 +28,7 @@ export class ExperienceMinerRole {
this.runtime = runtime
}
async run(mine_spec: { task_ids: string[]; focus_categories?: string[] }): Promise<ExperienceMinerResult> {
async run(mine_spec: { task_ids?: string[]; focus_categories?: string[]; source_refs?: Array<Record<string, unknown>>; evidence_refs?: string[] }): Promise<ExperienceMinerResult> {
const result: ExperienceMinerResult = {
status: 'no_patterns',
entries: [],
@@ -34,68 +36,73 @@ export class ExperienceMinerRole {
}
try {
this.runtime.emit('mining.started', { task_ids: mine_spec.task_ids })
const task_ids = mine_spec.task_ids ?? []
const evidence_refs = mine_spec.evidence_refs ?? task_ids.map((task_id) => `task:${task_id}`)
const focus = mine_spec.focus_categories?.length ? mine_spec.focus_categories : ['project_rule', 'toolchain_rule', 'debug_experience']
// Read completed task results to extract patterns
const task_summaries: string[] = []
for (const task_id of mine_spec.task_ids) {
try {
// Emit that we're reading a task
this.runtime.emit('mining.task', { task_id })
task_summaries.push(`Task ${task_id}: completed`)
} catch { /* skip failed task reads */ }
}
if (task_summaries.length === 0) {
result.status = 'no_patterns'
result.summary = 'No completed tasks available for mining'
if (task_ids.length === 0 && evidence_refs.length === 0) {
result.summary = 'No task or evidence refs available for memory mining'
return result
}
// Use LLM to extract patterns
const messages = [
{ role: 'system', content: 'You are an experience mining expert. Extract reusable patterns, best practices, and lessons learned from completed tasks. Output one pattern per line in format: CATEGORY: pattern description' },
{ role: 'user', content: `Analyze these completed tasks and extract reusable patterns:\n${task_summaries.join('\n')}\n\nFocus categories: ${(mine_spec.focus_categories || ['implementation', 'debugging', 'testing']).join(', ')}` }
{ role: 'system', content: 'Extract durable learning candidates only when supported by evidence. Output one candidate per line as memory_type: concise summary. Valid memory_type values: project_rule, toolchain_rule, skill_update, debug_experience. Do not promote or archive memories.' },
{ role: 'user', content: `Evidence refs:\n${evidence_refs.join('\n')}\n\nTask ids: ${task_ids.join(', ') || '(none)'}\nFocus categories: ${focus.join(', ')}` }
]
try {
const analysis = await this.runtime.call_llm({ messages, max_tokens: 2048, temperature: 0.3 })
const lines = (analysis.content || '').split('\n').filter(l => l.includes(':'))
for (const line of lines) {
const analysis = await this.runtime.call_llm({ messages, max_tokens: 2048, temperature: 0.2 })
for (const line of (analysis.content || '').split('\n')) {
const colon_idx = line.indexOf(':')
if (colon_idx > 0) {
const category = line.slice(0, colon_idx).trim().toLowerCase()
const pattern = line.slice(colon_idx + 1).trim()
if (pattern.length > 5) {
result.entries.push({
category,
pattern,
source_task_id: mine_spec.task_ids[0] || '',
description: pattern
})
}
}
if (colon_idx <= 0) continue
const category = line.slice(0, colon_idx).trim().toLowerCase()
const memory_type = MEMORY_TYPES.has(category) ? category : 'project_rule'
const pattern = line.slice(colon_idx + 1).trim()
if (pattern.length < 8) continue
result.entries.push({ category: memory_type, pattern, source_task_id: task_ids[0] || '', description: pattern })
}
} catch {
// LLM unavailable — extract basic patterns from task metadata
result.entries.push({
category: 'execution',
pattern: 'Tasks completed via Executor→LLM→Tool loop',
source_task_id: mine_spec.task_ids[0] || '',
description: 'Standard execution pattern for code changes'
category: 'project_rule',
pattern: `Review evidence before promoting memory from ${evidence_refs[0] || task_ids[0]}`,
source_task_id: task_ids[0] || '',
description: 'LLM unavailable; created a conservative candidate that requires human/runtime review before promotion.',
})
}
if (result.entries.length === 0 && evidence_refs.length > 0) {
const category = focus.find((item) => MEMORY_TYPES.has(item)) || 'project_rule'
result.entries.push({
category,
pattern: `Review evidence before promoting memory from ${evidence_refs[0]}`,
source_task_id: task_ids[0] || '',
description: 'Created a conservative candidate because no structured LLM-supported pattern was returned.',
})
}
for (const entry of result.entries) {
const candidate_id = `mem_${crypto.randomUUID()}`
this.runtime.emit('memory.candidate.created', {
event_id: `evt_${candidate_id}`,
candidate_id,
source_ref: {
entity_type: entry.source_task_id ? 'task' : 'evidence',
entity_id: entry.source_task_id || evidence_refs[0] || '',
},
memory_type: entry.category,
summary: entry.pattern,
evidence_refs,
})
}
if (result.entries.length === 0) {
result.status = 'no_patterns'
result.summary = `No patterns extracted from ${mine_spec.task_ids.length} tasks`
result.summary = `No supported memory candidates extracted from ${task_ids.length} tasks`
} else {
result.status = 'completed'
result.summary = `Mined ${result.entries.length} patterns from ${mine_spec.task_ids.length} tasks`
result.summary = `Created ${result.entries.length} memory candidates from ${task_ids.length} tasks`
}
this.runtime.checkpoint('mining_completed', { patterns_found: result.entries.length })
this.runtime.checkpoint('experience_mining_completed', { candidates: result.entries.length })
return result
} catch (error) {

View File

@@ -31,7 +31,7 @@ export class ReviewerRole {
const result: ReviewerResult = { status: 'pass', findings: [], summary: '' }
try {
this.runtime.emit('review.started', { task_id: review_spec.task_id })
this.runtime.checkpoint('review_started', { task_id: review_spec.task_id })
for (const file of review_spec.change_files) {
try {