Files
maestro/test/orchestrator.test.ts
T
wangjia 217eb6aa1f feat(merge): 自动合并失败时建最高优先级补救任务来完成合并
decide 端 exec_review accept→merge 冲突失败时,不再只是报错留在闸:
自动建一个 P0 easy 补救任务(operations 写明分支/目标/冲突详情与解冲突步骤),
自治项目会自动领取去解决。幂等:补救任务 id 记到原任务 result.mergeTaskId,
已有未结束补救任务则复用不重复建;结束(done/cancelled)后允许新建。

- model/types: TaskResult 加 mergeTaskId 字段
- mappers/orchestrator: 同步透传/补 null
- store.ensureMergeRemediationTask + store.test 覆盖(P0/easy/ready/operations/幂等/可重建)

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

337 lines
14 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',
};
const okSecurity: ReviewResult = {
summary: '## 安全审计\nmock 审计通过',
verdict: 'approve',
transcriptRef: '/tmp/fake-security.jsonl',
sessionId: 'sess-security-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,
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<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(四字段) + 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',
mergeTaskId: null,
});
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=nullcode 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();
});