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:
@@ -1,59 +1,55 @@
|
||||
/**
|
||||
* Regression test: EvidenceStore SQLite persistence
|
||||
*
|
||||
* Verifies that EvidenceStore uses SQLite (bun:sqlite) instead of
|
||||
* in-memory Map for persistent storage.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { readFileSync } from 'fs'
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { Database } from 'bun:sqlite'
|
||||
import { mkdtempSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
const SOURCE_PATH = join(
|
||||
import.meta.dir,
|
||||
'..',
|
||||
'..',
|
||||
'src',
|
||||
'artifacts',
|
||||
'EvidenceStore.ts'
|
||||
)
|
||||
|
||||
const source = readFileSync(SOURCE_PATH, 'utf-8')
|
||||
import { createEvidenceStore } from '../../src/artifacts/EvidenceStore.js'
|
||||
import { createNullEventIngestor } from '../../src/events/EventIngestor.js'
|
||||
|
||||
describe('EvidenceStore SQLite persistence', () => {
|
||||
test('EvidenceStore does not use in-memory Map', () => {
|
||||
// Should not have Map< for storage
|
||||
expect(source).not.toMatch(/evidenceStore:\s*Map</)
|
||||
// Should not use .set() on a map
|
||||
expect(source).not.toContain('this.evidenceStore.set(')
|
||||
const created: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('EvidenceStore constructor accepts Database parameter', () => {
|
||||
// Constructor should accept a Database parameter
|
||||
expect(source).toContain('db: Database')
|
||||
// Should import Database from bun:sqlite
|
||||
expect(source).toContain("from 'bun:sqlite'")
|
||||
})
|
||||
test('persists evidence refs in SQLite and can list them by entity', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'air-evidence-store-'))
|
||||
created.push(dir)
|
||||
const dbPath = join(dir, 'evidence.db')
|
||||
|
||||
test('EvidenceStore has initSchema method', () => {
|
||||
expect(source).toContain('initSchema()')
|
||||
// Should be called in constructor
|
||||
expect(source).toContain('this.initSchema()')
|
||||
})
|
||||
const db1 = new Database(dbPath)
|
||||
const store1 = createEvidenceStore('session_evidence' as any, db1, createNullEventIngestor() as any)
|
||||
const createdRef = await store1.create({
|
||||
kind: 'command_output',
|
||||
ref: 'artifact://stdout.txt',
|
||||
claim: 'command produced expected output',
|
||||
task_id: 'task_1' as any,
|
||||
location_json: { line: 1 },
|
||||
})
|
||||
expect(createdRef.evidence_ref_id).toStartWith('evi_')
|
||||
expect((await store1.list_for_entity('task', 'task_1'))[0]).toMatchObject({
|
||||
evidence_ref_id: createdRef.evidence_ref_id,
|
||||
kind: 'command_output',
|
||||
ref: 'artifact://stdout.txt',
|
||||
claim: 'command produced expected output',
|
||||
location_json: { line: 1 },
|
||||
})
|
||||
db1.close()
|
||||
|
||||
test('EvidenceStore creates evidence_refs table', () => {
|
||||
expect(source).toContain('CREATE TABLE IF NOT EXISTS evidence_refs')
|
||||
// Should have key columns
|
||||
expect(source).toContain('evidence_ref_id TEXT PRIMARY KEY')
|
||||
expect(source).toContain('session_id TEXT NOT NULL')
|
||||
expect(source).toContain('kind TEXT NOT NULL')
|
||||
})
|
||||
|
||||
test('EvidenceStore uses INSERT INTO for create', () => {
|
||||
expect(source).toContain('INSERT INTO evidence_refs')
|
||||
})
|
||||
|
||||
test('EvidenceStore applies WAL PRAGMA', () => {
|
||||
expect(source).toContain('PRAGMA journal_mode = WAL')
|
||||
const db2 = new Database(dbPath)
|
||||
const rows = db2.query('SELECT evidence_ref_id, session_id, kind, ref, claim, location_json, task_id FROM evidence_refs').all() as any[]
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({
|
||||
evidence_ref_id: createdRef.evidence_ref_id,
|
||||
session_id: 'session_evidence',
|
||||
kind: 'command_output',
|
||||
ref: 'artifact://stdout.txt',
|
||||
claim: 'command produced expected output',
|
||||
task_id: 'task_1',
|
||||
})
|
||||
expect(JSON.parse(rows[0].location_json)).toEqual({ line: 1 })
|
||||
expect(db2.query("PRAGMA journal_mode").get()).toEqual({ journal_mode: 'wal' })
|
||||
db2.close()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,111 +1,88 @@
|
||||
/**
|
||||
* C1 regression: Knowledge Store schema alignment.
|
||||
* Bug: DebugKnowledgeStore and LearnedMemoryStore used .air/shared/ paths,
|
||||
* had non-canonical column names, and were missing PRAGMAs.
|
||||
* Fix: moved to .air/local/, renamed columns, added WAL/synchronous/foreign_keys PRAGMAs.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test'
|
||||
import { readFileSync } from 'fs'
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import { existsSync, mkdtempSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { DebugKnowledgeStore } from '../../src/knowledge/DebugKnowledgeStore.js'
|
||||
import { LearnedMemoryStore } from '../../src/knowledge/LearnedMemoryStore.js'
|
||||
|
||||
describe('C1: Knowledge Store schema alignment', () => {
|
||||
const debug_src = readFileSync(
|
||||
join(import.meta.dir, '..', '..', 'src', 'knowledge', 'DebugKnowledgeStore.ts'),
|
||||
'utf-8'
|
||||
)
|
||||
const memory_src = readFileSync(
|
||||
join(import.meta.dir, '..', '..', 'src', 'knowledge', 'LearnedMemoryStore.ts'),
|
||||
'utf-8'
|
||||
)
|
||||
const created: string[] = []
|
||||
|
||||
it('DebugKnowledgeStore DB path uses .air/local/ not .air/shared/', () => {
|
||||
expect(debug_src).toContain("'.air', 'local', 'debug-records.db'")
|
||||
expect(debug_src).not.toContain("'.air', 'shared', 'debug-records.db'")
|
||||
afterEach(() => {
|
||||
for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('LearnedMemoryStore DB path uses .air/local/ not .air/shared/', () => {
|
||||
expect(memory_src).toContain("'.air', 'local', 'learned-memory.db'")
|
||||
expect(memory_src).not.toContain("'.air', 'shared', 'learned-memory.db'")
|
||||
it('DebugKnowledgeStore stores and queries records from .air/local', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'air-debug-store-'))
|
||||
created.push(root)
|
||||
const store = new DebugKnowledgeStore(root)
|
||||
store.open()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
store.insert({
|
||||
id: 'debug_1',
|
||||
failure_signature: 'compiler:error:missing-header',
|
||||
task_id: 'task_1',
|
||||
root_cause: 'missing include path',
|
||||
fix_ref: 'fix://1',
|
||||
summary: 'Add include path before rebuilding',
|
||||
evidence_json: JSON.stringify(['evi_1']),
|
||||
verification_json: JSON.stringify(['build passed']),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
metadata_json: JSON.stringify({ source: 'test' }),
|
||||
})
|
||||
|
||||
expect(existsSync(join(root, '.air', 'local', 'debug-records.db'))).toBe(true)
|
||||
expect(existsSync(join(root, '.air', 'shared', 'debug-records.db'))).toBe(false)
|
||||
expect(store.lookup_by_signature('compiler:error:missing-header')).toHaveLength(1)
|
||||
expect(store.lookup_by_task('task_1')[0]).toMatchObject({
|
||||
id: 'debug_1',
|
||||
failure_signature: 'compiler:error:missing-header',
|
||||
task_id: 'task_1',
|
||||
root_cause: 'missing include path',
|
||||
fix_ref: 'fix://1',
|
||||
summary: 'Add include path before rebuilding',
|
||||
})
|
||||
|
||||
store.update('debug_1', { summary: 'Updated summary', updated_at: now })
|
||||
expect(store.lookup_by_signature('compiler:error:missing-header')[0].summary).toBe('Updated summary')
|
||||
})
|
||||
|
||||
it('DebugRecord has failure_signature not signature', () => {
|
||||
const iface_match = debug_src.match(/export interface DebugRecord\s*\{([\s\S]*?)\}/)
|
||||
expect(iface_match).not.toBeNull()
|
||||
const iface_body = iface_match![1]
|
||||
it('LearnedMemoryStore stores candidates/promoted memories from .air/local', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'air-memory-store-'))
|
||||
created.push(root)
|
||||
const store = new LearnedMemoryStore(root)
|
||||
store.open()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
expect(iface_body).toContain('failure_signature')
|
||||
// Should not have bare 'signature' field (failure_signature contains 'signature' as substring, so check for the exact field pattern)
|
||||
expect(iface_body).not.toMatch(/^\s*signature\s*:/m)
|
||||
})
|
||||
store.insert({
|
||||
id: 'mem_1',
|
||||
memory_type: 'project_rule',
|
||||
summary: 'Use Bun for package scripts',
|
||||
content: 'Project commands should use Bun unless explicitly overridden.',
|
||||
source_entity_type: 'task',
|
||||
source_entity_id: 'task_1',
|
||||
status: 'candidate',
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
metadata_json: JSON.stringify({ confidence: 0.8 }),
|
||||
})
|
||||
|
||||
it('DebugRecord has summary and fix_ref fields', () => {
|
||||
const iface_match = debug_src.match(/export interface DebugRecord\s*\{([\s\S]*?)\}/)
|
||||
expect(iface_match).not.toBeNull()
|
||||
const iface_body = iface_match![1]
|
||||
expect(existsSync(join(root, '.air', 'local', 'learned-memory.db'))).toBe(true)
|
||||
expect(existsSync(join(root, '.air', 'shared', 'learned-memory.db'))).toBe(false)
|
||||
expect(store.lookup_by_type('project_rule')).toHaveLength(1)
|
||||
expect(store.lookup_by_type('project_rule')[0]).toMatchObject({
|
||||
id: 'mem_1',
|
||||
memory_type: 'project_rule',
|
||||
status: 'candidate',
|
||||
source_entity_type: 'task',
|
||||
source_entity_id: 'task_1',
|
||||
})
|
||||
|
||||
expect(iface_body).toContain('summary')
|
||||
expect(iface_body).toContain('fix_ref')
|
||||
})
|
||||
|
||||
it('DebugRecord does not have error_kind or session_id', () => {
|
||||
const iface_match = debug_src.match(/export interface DebugRecord\s*\{([\s\S]*?)\}/)
|
||||
expect(iface_match).not.toBeNull()
|
||||
const iface_body = iface_match![1]
|
||||
|
||||
expect(iface_body).not.toContain('error_kind')
|
||||
expect(iface_body).not.toContain('session_id')
|
||||
})
|
||||
|
||||
it('DebugKnowledgeStore applies WAL PRAGMA', () => {
|
||||
expect(debug_src).toContain('PRAGMA journal_mode = WAL')
|
||||
})
|
||||
|
||||
it('LearnedMemoryStore table is learned_memories (plural)', () => {
|
||||
expect(memory_src).toContain('learned_memories')
|
||||
// Ensure we don't have the singular form used as table name
|
||||
expect(memory_src).not.toMatch(/FROM learned_memory\b/)
|
||||
expect(memory_src).not.toMatch(/INTO learned_memory\b/)
|
||||
expect(memory_src).not.toMatch(/UPDATE learned_memory\b/)
|
||||
expect(memory_src).not.toMatch(/TABLE.*learned_memory\b/)
|
||||
})
|
||||
|
||||
it('MemoryEntry.memory_type has 4 spec values', () => {
|
||||
const iface_match = memory_src.match(/export interface MemoryEntry\s*\{([\s\S]*?)\}/)
|
||||
expect(iface_match).not.toBeNull()
|
||||
const iface_body = iface_match![1]
|
||||
|
||||
expect(iface_body).toContain("'project_rule'")
|
||||
expect(iface_body).toContain("'toolchain_rule'")
|
||||
expect(iface_body).toContain("'skill_update'")
|
||||
expect(iface_body).toContain("'debug_experience'")
|
||||
expect(iface_body).toContain('memory_type')
|
||||
})
|
||||
|
||||
it('MemoryEntry.status has 4 spec values: candidate, promoted, archived, rejected', () => {
|
||||
const iface_match = memory_src.match(/export interface MemoryEntry\s*\{([\s\S]*?)\}/)
|
||||
expect(iface_match).not.toBeNull()
|
||||
const iface_body = iface_match![1]
|
||||
|
||||
expect(iface_body).toContain("'candidate'")
|
||||
expect(iface_body).toContain("'promoted'")
|
||||
expect(iface_body).toContain("'archived'")
|
||||
expect(iface_body).toContain("'rejected'")
|
||||
})
|
||||
|
||||
it('MemoryEntry.status default is candidate not draft', () => {
|
||||
// Check that the CREATE TABLE DDL uses 'candidate' as default
|
||||
expect(memory_src).toContain("DEFAULT 'candidate'")
|
||||
expect(memory_src).not.toContain("DEFAULT 'draft'")
|
||||
})
|
||||
|
||||
it('MemoryEntry uses source_entity_type + source_entity_id', () => {
|
||||
const iface_match = memory_src.match(/export interface MemoryEntry\s*\{([\s\S]*?)\}/)
|
||||
expect(iface_match).not.toBeNull()
|
||||
const iface_body = iface_match![1]
|
||||
|
||||
expect(iface_body).toContain('source_entity_type')
|
||||
expect(iface_body).toContain('source_entity_id')
|
||||
expect(iface_body).not.toContain('source_task_ids')
|
||||
store.update_status('mem_1', 'promoted')
|
||||
expect(store.lookup_by_type('project_rule')[0].status).toBe('promoted')
|
||||
store.update_status('mem_1', 'archived')
|
||||
expect(store.lookup_by_type('project_rule')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
131
packages/runtime/test/regression/projection-store-apply.test.ts
Executable file
131
packages/runtime/test/regression/projection-store-apply.test.ts
Executable file
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { ProjectionStore } from '../../src/projection/ProjectionStore.js'
|
||||
import type { RuntimeEvent } from '@aircoding/contracts'
|
||||
|
||||
function event(type: string, payload: Record<string, unknown>): RuntimeEvent<Record<string, unknown>> {
|
||||
return {
|
||||
id: `evt_${type}_${Math.random().toString(36).slice(2)}`,
|
||||
type,
|
||||
version: 1,
|
||||
timestamp: new Date().toISOString(),
|
||||
session_id: 'session_projection_apply' as any,
|
||||
project_id: 'project_projection_apply' as any,
|
||||
source: { kind: 'system' },
|
||||
route: ['test', type],
|
||||
payload,
|
||||
}
|
||||
}
|
||||
|
||||
describe('ProjectionStore.apply', () => {
|
||||
it('applies task and agent lifecycle events into a live snapshot', () => {
|
||||
const store = new ProjectionStore()
|
||||
const updates: string[] = []
|
||||
store.subscribe((projection) => {
|
||||
updates.push(`${projection.tasks[0]?.status || 'none'}:${projection.agents[0]?.status || 'none'}`)
|
||||
})
|
||||
|
||||
store.apply(event('task.created', {
|
||||
task_id: 'task_1',
|
||||
type: 'execute',
|
||||
title: 'Create file',
|
||||
task_spec_json: {},
|
||||
dependencies: [],
|
||||
metadata: {},
|
||||
}))
|
||||
store.apply(event('task.started', {
|
||||
task_id: 'task_1',
|
||||
agent_id: 'agent_task_1',
|
||||
attempt_id: 'task_1_1',
|
||||
attempt_index: 0,
|
||||
workspace_id: 'ws_task_1',
|
||||
}))
|
||||
store.apply(event('agent.started', {
|
||||
agent_id: 'agent_task_1',
|
||||
agent_type: 'executor',
|
||||
task_id: 'task_1',
|
||||
metadata: {},
|
||||
}))
|
||||
store.apply(event('agent.completed', {
|
||||
agent_id: 'agent_task_1',
|
||||
task_id: 'task_1',
|
||||
summary: 'done',
|
||||
metadata: {},
|
||||
}))
|
||||
store.apply(event('task.completed', {
|
||||
task_id: 'task_1',
|
||||
agent_id: 'agent_task_1',
|
||||
attempt_id: 'task_1_1',
|
||||
worker_result_json: { status: 'completed' },
|
||||
summary: 'done',
|
||||
changed_files: ['hello.txt'],
|
||||
evidence_refs: [],
|
||||
}))
|
||||
|
||||
const snapshot = store.get_snapshot('session_projection_apply')
|
||||
expect(snapshot).toBeDefined()
|
||||
expect(snapshot!.tasks).toHaveLength(1)
|
||||
expect(snapshot!.tasks[0].status).toBe('completed')
|
||||
expect(snapshot!.tasks[0].agent_id).toBe('agent_task_1')
|
||||
expect(snapshot!.tasks[0].attempts).toBe(1)
|
||||
expect(snapshot!.agents).toHaveLength(1)
|
||||
expect(snapshot!.agents[0].status).toBe('completed')
|
||||
expect(updates.some((u) => u.startsWith('completed:completed'))).toBe(true)
|
||||
})
|
||||
|
||||
it('applies tool, permission, and blocker events', () => {
|
||||
const store = new ProjectionStore()
|
||||
|
||||
store.apply(event('tool.started', {
|
||||
tool_run_id: 'tool_1',
|
||||
tool_name: 'fs.write',
|
||||
input_json: {},
|
||||
metadata: {},
|
||||
}))
|
||||
store.apply(event('tool.completed', {
|
||||
tool_run_id: 'tool_1',
|
||||
output_json: { ok: true },
|
||||
duration_ms: 12,
|
||||
artifact_ids: [],
|
||||
evidence_refs: [],
|
||||
metadata: {},
|
||||
}))
|
||||
store.apply(event('permission.prompt.requested', {
|
||||
prompt_id: 'perm_1',
|
||||
subject: 'shell.run',
|
||||
risk_level: 'medium',
|
||||
reason: 'risk score 70 requires user confirmation',
|
||||
options: ['allow_once', 'deny'],
|
||||
default_option: 'deny',
|
||||
request_ref: {},
|
||||
}))
|
||||
store.apply(event('task.created', {
|
||||
task_id: 'task_blocked',
|
||||
type: 'execute',
|
||||
title: 'Blocked task',
|
||||
task_spec_json: {},
|
||||
dependencies: [],
|
||||
metadata: {},
|
||||
}))
|
||||
store.apply(event('task.blocked', {
|
||||
task_id: 'task_blocked',
|
||||
agent_id: 'agent_task_blocked',
|
||||
reason: 'worker blocked',
|
||||
blocker_kind: 'worker_blocked',
|
||||
evidence_refs: [],
|
||||
suggested_next_step: 'review blocker',
|
||||
}))
|
||||
store.apply(event('permission.prompt.resolved', {
|
||||
prompt_id: 'perm_1',
|
||||
selected_option: 'deny',
|
||||
decision_id: 'decision_1',
|
||||
resolved_by: 'test',
|
||||
}))
|
||||
|
||||
const snapshot = store.get_snapshot('session_projection_apply')
|
||||
expect(snapshot).toBeDefined()
|
||||
expect(snapshot!.tool_runs).toEqual([{ tool_run_id: 'tool_1', tool_name: 'fs.write', status: 'ok', duration_ms: 12 }])
|
||||
expect(snapshot!.permission_prompts).toHaveLength(0)
|
||||
expect(snapshot!.tasks.find((task) => task.id === 'task_blocked')?.status).toBe('blocked')
|
||||
expect(snapshot!.blockers).toEqual([{ task_id: 'task_blocked', reason: 'worker blocked', blocker_kind: 'worker_blocked' }])
|
||||
})
|
||||
})
|
||||
@@ -1,55 +1,95 @@
|
||||
/**
|
||||
* Regression test: Recovery implementation completeness
|
||||
*
|
||||
* Verifies that checkPidLiveness and scanOrphanReferences have real
|
||||
* implementations, not just stub return values.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { readFileSync } from 'fs'
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { Database } from 'bun:sqlite'
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
const SOURCE_PATH = join(
|
||||
import.meta.dir,
|
||||
'..',
|
||||
'..',
|
||||
'src',
|
||||
'storage',
|
||||
'Recovery.ts'
|
||||
)
|
||||
|
||||
const source = readFileSync(SOURCE_PATH, 'utf-8')
|
||||
import { Recovery } from '../../src/storage/Recovery.js'
|
||||
|
||||
describe('Recovery implementation', () => {
|
||||
test('checkPidLiveness is not a stub (has implementation code)', () => {
|
||||
// Should have actual implementation with loop logic
|
||||
expect(source).toContain('for (const agent of agents)')
|
||||
expect(source).toContain("action: alive ? 'keep' : 'mark_lost'")
|
||||
// Should have more than just a bare return []
|
||||
expect(source).toContain('const reports: PidLivenessReport[] = []')
|
||||
const created: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('checkPidLiveness uses process.kill for liveness check', () => {
|
||||
// Should use process.kill(pid, 0) for signal-0 liveness check
|
||||
expect(source).toContain('process.kill(agent.pid, 0)')
|
||||
function makeRecovery(): { recovery: Recovery; root: string; artifactRoot: string; dbPath: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), 'air-recovery-'))
|
||||
created.push(root)
|
||||
const artifactRoot = join(root, 'artifacts')
|
||||
const dbPath = join(root, 'session.db')
|
||||
const db = new Database(dbPath)
|
||||
db.exec(`
|
||||
CREATE TABLE sessions (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE tasks (id TEXT PRIMARY KEY, session_id TEXT);
|
||||
CREATE TABLE messages (id TEXT PRIMARY KEY, session_id TEXT);
|
||||
CREATE TABLE task_attempts (id TEXT PRIMARY KEY, task_id TEXT);
|
||||
CREATE TABLE agents (id TEXT PRIMARY KEY, session_id TEXT);
|
||||
CREATE TABLE tool_runs (id TEXT PRIMARY KEY, session_id TEXT);
|
||||
CREATE TABLE command_runs (id TEXT PRIMARY KEY, session_id TEXT);
|
||||
CREATE TABLE artifacts (id TEXT PRIMARY KEY, session_id TEXT);
|
||||
CREATE TABLE evidence_refs (evidence_ref_id TEXT PRIMARY KEY, session_id TEXT);
|
||||
INSERT INTO tasks (id, session_id) VALUES ('task_orphan', 'missing_session');
|
||||
INSERT INTO task_attempts (id, task_id) VALUES ('attempt_orphan', 'missing_task');
|
||||
`)
|
||||
db.close()
|
||||
return {
|
||||
recovery: new Recovery({
|
||||
sessionId: 'session_recovery' as any,
|
||||
projectId: 'project_recovery' as any,
|
||||
artifactRoot,
|
||||
dbPath,
|
||||
projectRoot: root,
|
||||
}),
|
||||
root,
|
||||
artifactRoot,
|
||||
dbPath,
|
||||
}
|
||||
}
|
||||
|
||||
test('checks PID liveness with keep/mark_lost actions', () => {
|
||||
const { recovery } = makeRecovery()
|
||||
const reports = recovery.checkPidLiveness([
|
||||
{ agent_id: 'self', pid: process.pid },
|
||||
{ agent_id: 'missing', pid: 99999999 },
|
||||
])
|
||||
recovery.close()
|
||||
|
||||
expect(reports).toEqual([
|
||||
{ agent_id: 'self', pid: process.pid, alive: true, action: 'keep' },
|
||||
{ agent_id: 'missing', pid: 99999999, alive: false, action: 'mark_lost' },
|
||||
])
|
||||
})
|
||||
|
||||
test('scanOrphanReferences returns OrphanReferenceReport structure', () => {
|
||||
// Should define fkChecks array with the 8 invariant checks
|
||||
expect(source).toContain('fkChecks')
|
||||
expect(source).toContain("table: 'tasks'")
|
||||
expect(source).toContain("table: 'messages'")
|
||||
expect(source).toContain("table: 'task_attempts'")
|
||||
expect(source).toContain("table: 'agents'")
|
||||
expect(source).toContain("table: 'tool_runs'")
|
||||
expect(source).toContain("table: 'command_runs'")
|
||||
expect(source).toContain("table: 'artifacts'")
|
||||
expect(source).toContain("table: 'evidence_refs'")
|
||||
test('scans orphan references from SQLite tables', async () => {
|
||||
const { recovery } = makeRecovery()
|
||||
const report = await recovery.scan()
|
||||
recovery.close()
|
||||
|
||||
// Should iterate over checks
|
||||
expect(source).toContain('for (const check of fkChecks)')
|
||||
expect(report.orphanReferences.totalFound).toBeGreaterThanOrEqual(2)
|
||||
expect(report.orphanReferences.archived).toContainEqual({
|
||||
table: 'tasks',
|
||||
id: 'missing_session',
|
||||
reason: 'FK-off: session_id → sessions (1 rows)',
|
||||
})
|
||||
expect(report.orphanReferences.archived).toContainEqual({
|
||||
table: 'task_attempts',
|
||||
id: 'missing_task',
|
||||
reason: 'FK-off: task_id → tasks (1 rows)',
|
||||
})
|
||||
})
|
||||
|
||||
// Should return a proper report
|
||||
expect(source).toContain('return report')
|
||||
test('quarantines non-artifact temporary orphan files', async () => {
|
||||
const { recovery, artifactRoot } = makeRecovery()
|
||||
const tmpDir = join(artifactRoot, 'tmp')
|
||||
const orphanPath = join(tmpDir, 'scratch.tmp')
|
||||
await Bun.write(orphanPath, 'orphan')
|
||||
|
||||
const report = await recovery.scan()
|
||||
recovery.close()
|
||||
|
||||
expect(report.orphanArtifacts.totalFound).toBe(1)
|
||||
expect(report.orphanArtifacts.quarantined).toHaveLength(1)
|
||||
expect(existsSync(report.orphanArtifacts.quarantined[0])).toBe(true)
|
||||
expect(existsSync(orphanPath)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ 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'
|
||||
import { createNullEventIngestor } from '../../src/events/EventIngestor.js'
|
||||
|
||||
function createRegistry(projectRoot: string): ToolRegistry {
|
||||
const registry = new ToolRegistry(projectRoot)
|
||||
@@ -59,8 +60,8 @@ describe('Release critical gates', () => {
|
||||
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' }])
|
||||
const scheduler = new Scheduler({ session_id: 's' as any, project_id: 'p' as any, project_root: process.cwd() }, workerManager as any, createNullEventIngestor())
|
||||
await 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()
|
||||
@@ -68,6 +69,34 @@ describe('Release critical gates', () => {
|
||||
expect(scheduler.get_graph().get_tasks_by_status('completed').length).toBe(0)
|
||||
})
|
||||
|
||||
it('scheduler surfaces blocked worker results as BLOCKED, not COMPLETED', async () => {
|
||||
const workerManager = {
|
||||
has_running: () => false,
|
||||
get_handle_for_task: () => ({ worker_id: 'agent-task-1' }),
|
||||
get_result_for_task: () => ({
|
||||
task_id: 'task-1',
|
||||
agent_id: 'agent-task-1',
|
||||
agent_type: 'executor',
|
||||
status: 'blocked',
|
||||
summary: 'blocked by worker',
|
||||
changed_files: [],
|
||||
artifacts: [],
|
||||
verification: [],
|
||||
risks: [],
|
||||
follow_up_tasks: [],
|
||||
evidence_refs: [],
|
||||
result: {},
|
||||
}),
|
||||
}
|
||||
const scheduler = new Scheduler({ session_id: 's' as any, project_id: 'p' as any, project_root: process.cwd() }, workerManager as any, createNullEventIngestor())
|
||||
await scheduler.create_tasks([{ id: 'task-1' as any, type: 'execute', title: 'Task' }])
|
||||
scheduler.get_graph().update_status('task-1' as any, 'running')
|
||||
|
||||
const finalState = await scheduler.run_until_idle()
|
||||
expect(finalState).toBe('BLOCKED')
|
||||
expect(scheduler.get_graph().get_tasks_by_status('blocked').length).toBe(1)
|
||||
})
|
||||
|
||||
it('MainAgent answer mode uses assembled project context', async () => {
|
||||
const projectRoot = mkdtempSync(join(tmpdir(), 'air-context-'))
|
||||
writeFileSync(join(projectRoot, 'visible.txt'), 'visible')
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { describe, it, expect } from 'bun:test'
|
||||
import { Scheduler, type SchedulerState } from '../../src/scheduler/Scheduler.js'
|
||||
import { createNullEventIngestor } from '../../src/events/EventIngestor.js'
|
||||
|
||||
describe('B1: Scheduler wire-up', () => {
|
||||
it('SchedulerState includes BLOCKED and CANCELLED', () => {
|
||||
@@ -54,10 +55,12 @@ describe('B1: Scheduler wire-up', () => {
|
||||
session_id: 'test-session' as any,
|
||||
project_id: 'test-project' as any,
|
||||
project_root: '/tmp/test'
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
createNullEventIngestor(),
|
||||
)
|
||||
|
||||
scheduler.create_tasks([
|
||||
await scheduler.create_tasks([
|
||||
{ id: 't1' as any, type: 'code', title: 'Task 1' },
|
||||
{ id: 't2' as any, type: 'code', title: 'Task 2', depends_on: ['t1' as any] }
|
||||
])
|
||||
|
||||
@@ -50,8 +50,9 @@ describe('A5+A4: ToolRegistry permission fixes', () => {
|
||||
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'")
|
||||
it('permission branches preserve original call_id', () => {
|
||||
expect(src).toContain('permission.prompt.requested')
|
||||
expect(src).toContain('request_ref: { call_id: call.call_id')
|
||||
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'")
|
||||
|
||||
@@ -45,9 +45,13 @@ describe('D3: Worker result envelope', () => {
|
||||
})
|
||||
|
||||
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('agent_type: (payload.agent_type as AgentType) || this.worker_agent_type(handle.config.agent_id)')
|
||||
expect(source).toContain("const raw_status = (payload.status as string) || 'completed'")
|
||||
expect(source).toContain("raw_status === 'fixed' || raw_status === 'pass'")
|
||||
expect(source).toContain("raw_status === 'fixed'")
|
||||
expect(source).toContain("raw_status === 'pass'")
|
||||
expect(source).toContain("raw_status === 'cannot_reproduce'")
|
||||
expect(source).toContain("raw_status === 'compacted'")
|
||||
expect(source).toContain("raw_status === 'no_patterns'")
|
||||
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')
|
||||
|
||||
Reference in New Issue
Block a user