import { test } from 'node:test'; import assert from 'node:assert/strict'; import { Store } from '../src/store/index.js'; import { createOrchestrator, MAX_RETRIES, type OrchestratorDeps } from '../src/daemon/orchestrator.js'; import type { RunnerResult } from '../src/executor/runner.js'; import type { ReviewResult } from '../src/executor/reviewer.js'; import type { Autonomy } from '../src/model/types.js'; const noopLog = { info: (): void => undefined, error: (): void => undefined }; function deferred(): { promise: Promise; resolve: (v: T) => void } { let resolve!: (v: T) => void; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; } const okRun: RunnerResult = { ok: true, transcriptRef: '/tmp/fake.jsonl', sessionId: 'sess-mock-1', finalText: '执行自述:改了 README' }; const okReview: ReviewResult = { summary: '## 做了什么\nmock 复审通过', verdict: 'approve', transcriptRef: '/tmp/fake-review.jsonl', sessionId: 'sess-review-1', }; const okSecurity: ReviewResult = { summary: '## 安全审计\nmock 审计通过', verdict: 'approve', transcriptRef: '/tmp/fake-security.jsonl', sessionId: 'sess-security-1', }; /** 全 mock 依赖(不真起 CC、不动 git):可按用例覆盖 */ function mockDeps(overrides: Partial = {}): OrchestratorDeps { return { createWorktree: async (_repo, taskId) => ({ dir: `/tmp/fake-wt/${taskId}`, branch: `maestro/${taskId}` }), worktreeDiff: async () => ({ diffSummary: ' README.md | 1 +', commits: ['abc1234 hello maestro'] }), verify: async () => ({ ok: true, exitCode: 0, logRef: null }), runner: async () => okRun, reviewCode: async () => okReview, reviewSecurity: async () => okSecurity, ...overrides, }; } function setup(autonomy: Autonomy, concurrency = 1): { store: Store; projectId: string } { const store = new Store(':memory:'); const p = store.createProject({ name: 'orch', repoPath: '/tmp/orch-repo-' + Math.random(), autonomy, concurrency, }); return { store, projectId: p.id }; } async function settle(): Promise { await new Promise((r) => setImmediate(r)); } test('autonomy=manual:编排器不领取任何任务', async () => { const { store, projectId } = setup('manual'); const t = store.createTask({ projectId, title: 'easy task', complexity: 'easy' }); let calls = 0; const orch = createOrchestrator(store, noopLog, mockDeps({ runner: async () => { calls++; return okRun; }, })); orch.tick(); await orch.drain(); assert.equal(calls, 0); assert.equal(store.getTask(t.id)!.status, 'ready'); store.close(); }); test('autonomy=auto-easy:只领 easy,medium ready 不动', async () => { const { store, projectId } = setup('auto-easy', 5); const easy = store.createTask({ projectId, title: 'small', complexity: 'easy' }); const medium = store.createTask({ projectId, title: 'mid', complexity: 'medium' }); store.setSpec(medium.id, '方案'); store.transition(medium.id, 'spec_review'); store.decide(medium.id, 'accept', 'user'); // medium → ready assert.equal(store.getTask(medium.id)!.status, 'ready'); const ran: string[] = []; const orch = createOrchestrator(store, noopLog, mockDeps({ runner: async (task) => { ran.push(task.id); return okRun; }, })); orch.tick(); await orch.drain(); assert.deepEqual(ran, [easy.id]); assert.equal(store.getTask(easy.id)!.status, 'exec_review'); assert.equal(store.getTask(medium.id)!.status, 'ready'); // 不自动跑 store.close(); }); test('autonomy=auto-approved:领全部 ready(含 medium),依赖未满足/非叶子不领', async () => { const { store, projectId } = setup('auto-approved', 5); const medium = store.createTask({ projectId, title: 'mid', complexity: 'medium' }); store.setSpec(medium.id, '方案'); store.transition(medium.id, 'spec_review'); store.decide(medium.id, 'accept', 'user'); // 依赖未 done 的任务:建在 medium 上 → blocked,不可领 const dep = store.createTask({ projectId, title: 'after-mid', complexity: 'easy', deps: [medium.id] }); assert.equal(store.getTask(dep.id)!.status, 'blocked'); const ran: string[] = []; const orch = createOrchestrator(store, noopLog, mockDeps({ runner: async (task) => { ran.push(task.id); return okRun; }, })); orch.tick(); await orch.drain(); assert.deepEqual(ran, [medium.id]); assert.equal(store.getTask(medium.id)!.status, 'exec_review'); assert.equal(store.getTask(dep.id)!.status, 'blocked'); store.close(); }); test('concurrency=1:同项目同轮只领 1 个,跑完下一轮再领', async () => { const { store, projectId } = setup('auto-approved', 1); const t1 = store.createTask({ projectId, title: 'a', complexity: 'easy' }); const t2 = store.createTask({ projectId, title: 'b', complexity: 'easy' }); const gate = deferred(); const started: string[] = []; const orch = createOrchestrator(store, noopLog, mockDeps({ runner: async (task) => { started.push(task.id); await gate.promise; return okRun; }, })); orch.tick(); await settle(); assert.deepEqual(started, [t1.id], '并发=1 只应启动第一个任务'); assert.equal(store.getTask(t1.id)!.status, 'executing'); assert.equal(store.getTask(t2.id)!.status, 'ready'); assert.equal(orch.inflight.size, 1); orch.tick(); // 在途占满 → 本轮不领 await settle(); assert.deepEqual(started, [t1.id]); gate.resolve(); await orch.drain(); assert.equal(store.getTask(t1.id)!.status, 'exec_review'); orch.tick(); // 槽位释放 → 领第二个 gate.resolve(); await orch.drain(); assert.deepEqual(started, [t1.id, t2.id]); assert.equal(store.getTask(t2.id)!.status, 'exec_review'); store.close(); }); test('成功路径:状态流转 + setResult(四字段) + executor/reviewer/security 三 run succeeded', async () => { const { store, projectId } = setup('auto-easy'); const t = store.createTask({ projectId, title: 'tweak', complexity: 'easy' }); store.setOperations(t.id, '在 README.md 追加一行'); const seen: string[] = []; store.subscribe((e) => { if (e.type === 'status.changed' && e.taskId === t.id) seen.push(String(e.payload.to)); }); let codeReportSeen: string | null = null; let secReportSeen: string | null = null; const orch = createOrchestrator(store, noopLog, mockDeps({ reviewCode: async (_task, _project, _wt, _runId, executorReport) => { codeReportSeen = executorReport; return okReview; }, reviewSecurity: async (_task, _project, _wt, _runId, executorReport) => { secReportSeen = executorReport; return okSecurity; }, })); orch.tick(); await orch.drain(); const done = store.getTask(t.id)!; assert.equal(done.status, 'exec_review'); assert.deepEqual(seen, ['queued', 'executing', 'exec_review']); assert.deepEqual(done.result, { branch: `maestro/${t.id}`, worktree: `/tmp/fake-wt/${t.id}`, diffSummary: ' README.md | 1 +', commits: ['abc1234 hello maestro'], prUrl: null, summary: '## 做了什么\nmock 复审通过', verdict: 'approve', securitySummary: '## 安全审计\nmock 审计通过', securityVerdict: 'approve', }); assert.equal(codeReportSeen, '执行自述:改了 README'); // runner finalText 传给两个复审作执行者自述 assert.equal(secReportSeen, '执行自述:改了 README'); const runs = store.listRuns(t.id); assert.equal(runs.length, 3); const executor = runs.find((r) => r.kind === 'executor')!; assert.equal(executor.status, 'succeeded'); assert.equal(executor.branch, `maestro/${t.id}`); assert.equal(executor.transcriptRef, '/tmp/fake.jsonl'); assert.equal(executor.claudeSessionId, 'sess-mock-1'); const reviewer = runs.find((r) => r.kind === 'reviewer')!; assert.equal(reviewer.status, 'succeeded'); assert.equal(reviewer.transcriptRef, '/tmp/fake-review.jsonl'); assert.equal(reviewer.claudeSessionId, 'sess-review-1'); const security = runs.find((r) => r.kind === 'security')!; assert.equal(security.status, 'succeeded'); assert.equal(security.transcriptRef, '/tmp/fake-security.jsonl'); assert.equal(security.claudeSessionId, 'sess-security-1'); store.close(); }); test('任一 verdict=reject 也照常落进 result(最终裁决仍归用户)', async () => { const { store, projectId } = setup('auto-easy'); const t = store.createTask({ projectId, title: 'risky', complexity: 'easy' }); const orch = createOrchestrator(store, noopLog, mockDeps({ reviewCode: async () => ({ ...okReview, summary: '发现问题', verdict: 'reject' as const }), reviewSecurity: async () => ({ ...okSecurity, summary: '发现密钥泄露', verdict: 'reject' as const }), })); orch.tick(); await orch.drain(); const done = store.getTask(t.id)!; assert.equal(done.status, 'exec_review'); assert.equal(done.result!.verdict, 'reject'); assert.equal(done.result!.summary, '发现问题'); assert.equal(done.result!.securityVerdict, 'reject'); assert.equal(done.result!.securitySummary, '发现密钥泄露'); store.close(); }); test('code review 失败不挡任务:summary 记失败原因、verdict=null,安全审计照常跑', async () => { const { store, projectId } = setup('auto-easy'); const t = store.createTask({ projectId, title: 'review-broken', complexity: 'easy' }); const orch = createOrchestrator(store, noopLog, mockDeps({ reviewCode: async () => { throw new Error('复审 CC 崩了'); }, })); orch.tick(); await orch.drain(); const done = store.getTask(t.id)!; assert.equal(done.status, 'exec_review'); // 不挡结果闸 assert.equal(done.result!.verdict, null); assert.equal(done.result!.summary, '自动复审失败:复审 CC 崩了'); assert.equal(done.result!.securityVerdict, 'approve'); // 另一个复审不受影响 assert.equal(done.result!.securitySummary, '## 安全审计\nmock 审计通过'); const runs = store.listRuns(t.id); assert.equal(runs.find((r) => r.kind === 'executor')!.status, 'succeeded'); const reviewer = runs.find((r) => r.kind === 'reviewer')!; assert.equal(reviewer.status, 'failed'); assert.match(reviewer.error ?? '', /复审 CC 崩了/); assert.equal(runs.find((r) => r.kind === 'security')!.status, 'succeeded'); store.close(); }); test('安全审计失败不挡任务:securitySummary 记失败原因、securityVerdict=null,code review 不受影响', async () => { const { store, projectId } = setup('auto-easy'); const t = store.createTask({ projectId, title: 'security-broken', complexity: 'easy' }); const orch = createOrchestrator(store, noopLog, mockDeps({ reviewSecurity: async () => { throw new Error('审计 CC 崩了'); }, })); orch.tick(); await orch.drain(); const done = store.getTask(t.id)!; assert.equal(done.status, 'exec_review'); assert.equal(done.result!.verdict, 'approve'); assert.equal(done.result!.summary, '## 做了什么\nmock 复审通过'); assert.equal(done.result!.securityVerdict, null); assert.equal(done.result!.securitySummary, '自动复审失败:审计 CC 崩了'); const runs = store.listRuns(t.id); assert.equal(runs.find((r) => r.kind === 'executor')!.status, 'succeeded'); assert.equal(runs.find((r) => r.kind === 'reviewer')!.status, 'succeeded'); const security = runs.find((r) => r.kind === 'security')!; assert.equal(security.status, 'failed'); assert.match(security.error ?? '', /审计 CC 崩了/); store.close(); }); test('verify 不过:按失败处理(run failed + 重新入队)', async () => { const { store, projectId } = setup('auto-easy'); const t = store.createTask({ projectId, title: 'v', complexity: 'easy' }); const orch = createOrchestrator(store, noopLog, mockDeps({ verify: async () => ({ ok: false, exitCode: 1, logRef: '/tmp/v.log', error: 'verify 失败(exit 1)' }), })); orch.tick(); await orch.drain(); assert.equal(store.getTask(t.id)!.status, 'queued'); // 第一次失败 → 重新入队 const runs = store.listRuns(t.id); assert.equal(runs.length, 1); assert.equal(runs[0].status, 'failed'); assert.match(runs[0].error ?? '', /verify 失败/); store.close(); }); test(`失败重试:重试 ${MAX_RETRIES} 次后 → needs_attention(共 ${MAX_RETRIES + 1} 次失败 run)`, async () => { const { store, projectId } = setup('auto-easy'); const t = store.createTask({ projectId, title: 'flaky', complexity: 'easy' }); let attempts = 0; const orch = createOrchestrator(store, noopLog, mockDeps({ runner: async () => { attempts++; return { ok: false, transcriptRef: null, sessionId: null, error: `boom #${attempts}` }; }, })); for (let i = 1; i <= MAX_RETRIES; i++) { orch.tick(); await orch.drain(); assert.equal(store.getTask(t.id)!.status, 'queued', `第 ${i} 次失败后应重新入队`); } orch.tick(); // 最后一次重试也失败 await orch.drain(); assert.equal(store.getTask(t.id)!.status, 'needs_attention'); assert.equal(attempts, MAX_RETRIES + 1); const failed = store.listRuns(t.id).filter((r) => r.status === 'failed'); assert.equal(failed.length, MAX_RETRIES + 1); orch.tick(); // needs_attention 不会再被领取 await orch.drain(); assert.equal(attempts, MAX_RETRIES + 1); store.close(); }); test('project paused:不领取', async () => { const { store, projectId } = setup('auto-approved'); const t = store.createTask({ projectId, title: 'x', complexity: 'easy' }); store.patchProject(projectId, { status: 'paused' }); let calls = 0; const orch = createOrchestrator(store, noopLog, mockDeps({ runner: async () => { calls++; return okRun; }, })); orch.tick(); await orch.drain(); assert.equal(calls, 0); assert.equal(store.getTask(t.id)!.status, 'ready'); store.close(); });