Files
maestro/test/orchestrator.test.ts
T
wangjia f18db021c3 feat: Phase2 完整管线——score 调度 + 自动复审 + 模型分级 + 归档与详情
调度:
- model/scoring.ts: score = 自身分(P0=3/P1=2/P2=1) + 已完成依赖分(链条惯性)
  + 等待解锁的 blocked 任务分(解锁加权),编排器与 nextExecutable 同一打分
- createTask 校验 deps 存在且同项目(依赖图天然无环)
- daemon 重启中断自愈: executing 任务标 failed run 后重新入队(reconcileInterrupted)

执行管线:
- executor/cc.ts: 公共 headless CC 执行器(转录/超时/模型回退重试)
- executor/reviewer.ts: 执行后自动复审(只读 CC 审 diff),固定模板 summary
  (做了什么/怎么做/测试/CodeReview/安全Review/结论) + VERDICT 解析
- executor/models.ts: 按复杂度选模型(easy→sonnet/medium→opus/hard→fable5),
  env 可覆盖、project.model 最优先、不可用自动回退链
- runner: 测试/构建命令白名单(npm/go/shellcheck/make/pytest),prompt 要求实跑测试
- TaskResult 加 summary/verdict; RunKind 加 reviewer
- 容器收口: 已拆解 Hard 子任务全 done → 容器自动 done(afterDone 逐级向上)

看板:
- 五徽章组(待审批/待执行/执行中/被阻塞/总量,hover 展开,均不含已完成)
- 归档区: 深度1整树完成沉底,时间倒序分页(10/20/50/100 chip 选择)
- 归档详情对话框: 全属性/执行历史与时长/审批记录/状态流转时间线(含相关人或事)
- Agent 面板显示调度模式 + 各复杂度实际模型
- 结果闸展示复审 summary + 建议通过/拒绝徽章
- 筛选修复(组选与单选分离、已拆解移出进行中)、同步按钮收进配置面板、
  保存配置自动收起、预览全宽、被依赖阻塞→被阻塞
- API: GET /api/tasks/:id/events(任务级事件时间线)、/api/agents 带 scheduling/models

测试: 49/49(新增 scoring/复审/模型/容器收口/deps 校验/中断恢复)

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

285 lines
11 KiB
TypeScript
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.
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<T>(): { promise: Promise<T>; resolve: (v: T) => void } {
let resolve!: (v: T) => void;
const promise = new Promise<T>((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',
};
/** 全 mock 依赖(不真起 CC、不动 git):可按用例覆盖 */
function mockDeps(overrides: Partial<OrchestratorDeps> = {}): 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,
reviewer: async () => okReview,
...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<void> {
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:只领 easymedium 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<void>();
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(含复审 summary/verdict) + 双 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 reportSeen: string | null = null;
const orch = createOrchestrator(store, noopLog, mockDeps({
reviewer: async (_task, _project, _wt, _runId, executorReport) => {
reportSeen = executorReport;
return okReview;
},
}));
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',
});
assert.equal(reportSeen, '执行自述:改了 README'); // runner finalText 传给 reviewer 作执行者自述
const runs = store.listRuns(t.id);
assert.equal(runs.length, 2);
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');
store.close();
});
test('reviewer 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({
reviewer: async () => ({ ...okReview, 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, '发现问题');
store.close();
});
test('复审失败不挡任务:照常进 exec_reviewsummary 记失败原因、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({
reviewer: 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 崩了');
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 崩了/);
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();
});