Files
maestro/test/store.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

254 lines
12 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, StoreError } from '../src/store/index.js';
function freshStore(): Store {
return new Store(':memory:');
}
test('project + task creation, complexity routes initial status', () => {
const s = freshStore();
const p = s.createProject({ name: 'demo', repoPath: '/tmp/demo-' + Math.random() });
assert.equal(p.status, 'active');
const hard = s.createTask({ projectId: p.id, title: 'big', complexity: 'hard' });
const medium = s.createTask({ projectId: p.id, title: 'mid', complexity: 'medium' });
const easy = s.createTask({ projectId: p.id, title: 'small', complexity: 'easy' });
assert.equal(hard.status, 'analyzing'); // Hard 强制分析
assert.equal(medium.status, 'speccing'); // Medium 写方案
assert.equal(easy.status, 'ready'); // Easy 直接可执行
s.close();
});
test('Easy 路径:写操作 → ready → nextExecutable 领取', () => {
const s = freshStore();
const p = s.createProject({ name: 'e', repoPath: '/tmp/e-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'tweak', complexity: 'easy' });
s.setOperations(t.id, '改 README 一行');
const next = s.nextExecutable(p.id);
assert.equal(next?.id, t.id);
s.close();
});
test('Medium 闸:spec_review accept → readyreject 必带意见', () => {
const s = freshStore();
const p = s.createProject({ name: 'm', repoPath: '/tmp/m-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'feat', complexity: 'medium' });
s.setSpec(t.id, '加一个接口,因为前端需要');
const inReview = s.transition(t.id, 'spec_review');
assert.equal(inReview.status, 'spec_review');
// reject 无意见 → 报错
assert.throws(() => s.decide(t.id, 'reject', 'user'), StoreError);
// reject 带意见 → 回到 speccing
const back = s.decide(t.id, 'reject', 'user', '方案漏了鉴权');
assert.equal(back.status, 'speccing');
assert.equal(back.approvals.at(-1)?.reason, '方案漏了鉴权');
// 重新评审 → accept → ready
s.transition(t.id, 'spec_review');
const ok = s.decide(t.id, 'accept', 'user');
assert.equal(ok.status, 'ready');
s.close();
});
test('Hard 闸:plan_review accept → decomposed,子任务汇总', () => {
const s = freshStore();
const p = s.createProject({ name: 'h', repoPath: '/tmp/h-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'epic', complexity: 'hard' });
s.setPlan(t.id, '分析…拆 2 个子任务');
s.transition(t.id, 'plan_review');
const decomposed = s.decide(t.id, 'accept', 'user');
assert.equal(decomposed.status, 'decomposed');
const c1 = s.createTask({ projectId: p.id, parentId: t.id, title: '子1', complexity: 'easy' });
assert.equal(c1.depth, 2);
// 拆解后容器不应被 nextExecutable 领取(它有子任务且非 ready)
s.setOperations(c1.id, '做子1');
const next = s.nextExecutable(p.id);
assert.equal(next?.id, c1.id); // 取到叶子,不是容器
s.close();
});
test('层级上限:medium 默认 3 层,第 4 层拒绝', () => {
const s = freshStore();
const p = s.createProject({ name: 'd', repoPath: '/tmp/d-' + Math.random() });
const l1 = s.createTask({ projectId: p.id, title: 'l1', complexity: 'medium' });
const l2 = s.createTask({ projectId: p.id, parentId: l1.id, title: 'l2', complexity: 'medium' });
const l3 = s.createTask({ projectId: p.id, parentId: l2.id, title: 'l3', complexity: 'medium' });
assert.equal(l3.depth, 3);
assert.throws(() => s.createTask({ projectId: p.id, parentId: l3.id, title: 'l4', complexity: 'medium' }), StoreError);
s.close();
});
test('非法状态流转被守卫拒绝', () => {
const s = freshStore();
const p = s.createProject({ name: 'g', repoPath: '/tmp/g-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy' }); // ready
assert.throws(() => s.transition(t.id, 'done'), StoreError); // ready→done 非法
s.close();
});
test('exec_review 结果闸:accept → done', () => {
const s = freshStore();
const p = s.createProject({ name: 'x', repoPath: '/tmp/x-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'run', complexity: 'easy' });
s.setOperations(t.id, 'op');
s.transition(t.id, 'queued');
s.transition(t.id, 'executing');
s.setResult(t.id, { branch: 'maestro/run', worktree: '/wt', diffSummary: '+1 -0', commits: ['abc'], prUrl: null, summary: null, verdict: null, securitySummary: null, securityVerdict: null });
s.transition(t.id, 'exec_review');
const done = s.decide(t.id, 'accept', 'user');
assert.equal(done.status, 'done');
s.close();
});
test('依赖未满足时不可领取', () => {
const s = freshStore();
const p = s.createProject({ name: 'dep', repoPath: '/tmp/dep-' + Math.random() });
const a = s.createTask({ projectId: p.id, title: 'A', complexity: 'easy' });
const b = s.createTask({ projectId: p.id, title: 'B', complexity: 'easy', deps: [a.id] });
s.setOperations(a.id, 'a'); s.setOperations(b.id, 'b');
// 优先级相同时 A 先建,但 B 依赖 A 未 done → 只能取到 A
const next = s.nextExecutable(p.id);
assert.equal(next?.id, a.id);
s.close();
});
test('事件订阅:状态变更广播', () => {
const s = freshStore();
const got: string[] = [];
s.subscribe((e) => got.push(e.type));
const p = s.createProject({ name: 'ev', repoPath: '/tmp/ev-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 't', complexity: 'easy' });
s.transition(t.id, 'queued');
assert.ok(got.includes('task.created'));
assert.ok(got.includes('status.changed'));
s.close();
});
test('依赖自动落位:建任务带未完成依赖 → blocked;依赖 done → 自动放行 ready', () => {
const s = freshStore();
const p = s.createProject({ name: 'ab', repoPath: '/tmp/ab-' + Math.random() });
const a = s.createTask({ projectId: p.id, title: 'A', complexity: 'easy' });
const b = s.createTask({ projectId: p.id, title: 'B', complexity: 'easy', deps: [a.id] });
assert.equal(b.status, 'blocked'); // 建即落位 blocked,而非假 ready
// 手动把 blocked 拉成 ready 也会被按依赖弹回(仍 blocked)
assert.equal(s.transition(b.id, 'ready').status, 'blocked');
// A 走完整闭环到 done → B 自动放行
const evts = [];
s.subscribe((e) => evts.push(e));
s.transition(a.id, 'queued');
s.transition(a.id, 'executing');
s.transition(a.id, 'exec_review');
s.decide(a.id, 'accept', 'user');
assert.equal(s.getTask(b.id).status, 'ready'); // 自动 blocked→ready
const auto = evts.find((e) => e.taskId === b.id && e.payload.auto === 'deps-met');
assert.ok(auto, '应有 deps-met 自动放行事件');
s.close();
});
test('reconcileDeps:存量 ready 但依赖未满足 → 纠正为 blocked(幂等)', () => {
const s = freshStore();
const p = s.createProject({ name: 'rc', repoPath: '/tmp/rc-' + Math.random() });
const a = s.createTask({ projectId: p.id, title: 'A', complexity: 'easy' });
const b = s.createTask({ projectId: p.id, title: 'B', complexity: 'easy', deps: [a.id] });
// 模拟旧库脏数据:B 被直接写成 ready
s.db.prepare(`UPDATE tasks SET status = 'ready' WHERE id = ?`).run(b.id);
const r1 = s.reconcileDeps(p.id);
assert.equal(r1.blocked, 1);
assert.equal(s.getTask(b.id).status, 'blocked');
const r2 = s.reconcileDeps(p.id); // 幂等
assert.equal(r2.blocked + r2.released, 0);
s.close();
});
test('score 调度:解锁加权 > 链条惯性 > 自身分;nextExecutable 取最高分', () => {
const s = freshStore();
const p = s.createProject({ name: 'sc', repoPath: '/tmp/sc-' + Math.random() });
// AP1 无依赖,但有两条 P0 blocked 任务等它 → score = 2 + 3 + 3 = 8
const a = s.createTask({ projectId: p.id, title: 'A', complexity: 'easy', priority: 1 });
s.createTask({ projectId: p.id, title: 'W1', complexity: 'easy', priority: 0, deps: [a.id] });
s.createTask({ projectId: p.id, title: 'W2', complexity: 'easy', priority: 0, deps: [a.id] });
// BP0 无依赖无人等 → score = 3
s.createTask({ projectId: p.id, title: 'B', complexity: 'easy', priority: 0 });
const next = s.nextExecutable(p.id);
assert.equal(next?.title, 'A'); // 解锁两条 P0 的 A(8) 压过孤立 P0 的 B(3)
s.close();
});
test('createTaskdeps 引用不存在/跨项目任务被拒绝', () => {
const s = freshStore();
const p1 = s.createProject({ name: 'd1', repoPath: '/tmp/d1-' + Math.random() });
const p2 = s.createProject({ name: 'd2', repoPath: '/tmp/d2-' + Math.random() });
const other = s.createTask({ projectId: p2.id, title: 'x', complexity: 'easy' });
assert.throws(() => s.createTask({ projectId: p1.id, title: 'bad', complexity: 'easy', deps: ['tsk_nope'] }), StoreError);
assert.throws(() => s.createTask({ projectId: p1.id, title: 'bad2', complexity: 'easy', deps: [other.id] }), StoreError);
s.close();
});
test('容器收口:已拆解 Hard 的子任务全 done → 容器自动 done(逐级向上)', () => {
const s = freshStore();
const p = s.createProject({ name: 'cc', repoPath: '/tmp/cc-' + Math.random() });
const root = s.createTask({ projectId: p.id, title: 'epic', complexity: 'hard' });
s.setPlan(root.id, '拆 2 子');
s.transition(root.id, 'plan_review');
s.decide(root.id, 'accept', 'user'); // decomposed
const c1 = s.createTask({ projectId: p.id, parentId: root.id, title: '子1', complexity: 'easy' });
const c2 = s.createTask({ projectId: p.id, parentId: root.id, title: '子2', complexity: 'easy' });
const finish = (tid) => {
s.transition(tid, 'queued'); s.transition(tid, 'executing');
s.transition(tid, 'exec_review'); s.decide(tid, 'accept', 'user');
};
finish(c1.id);
assert.equal(s.getTask(root.id).status, 'decomposed'); // 还有子没完,容器不动
finish(c2.id);
assert.equal(s.getTask(root.id).status, 'done'); // 子全 done → 容器自动 done
s.close();
});
test('合并失败补救:建最高优先级 easy 任务,且幂等不重复建', () => {
const s = freshStore();
const p = s.createProject({ name: 'm', repoPath: '/tmp/m-' + Math.random() });
// 造一个进入 exec_review、带分支结果的原任务
const t = s.createTask({ projectId: p.id, title: '配置面板补 model', complexity: 'easy', priority: 1 });
s.setOperations(t.id, 'op');
s.transition(t.id, 'queued');
s.transition(t.id, 'executing');
s.setResult(t.id, {
branch: 'maestro/' + t.id, worktree: '/tmp/wt', diffSummary: '', commits: [], prUrl: null,
summary: null, verdict: null, securitySummary: null, securityVerdict: null, mergeTaskId: null,
});
s.transition(t.id, 'exec_review');
const rem = s.ensureMergeRemediationTask(t.id, {
branch: 'maestro/' + t.id, targetBranch: 'main', conflictError: '冲突文件:web/app.js',
});
assert.equal(rem.complexity, 'easy');
assert.equal(rem.priority, 0, '补救任务应为最高优先级 P0');
assert.equal(rem.status, 'ready', 'easy 无依赖 → 直达 ready,自治项目可领');
assert.match(rem.title, /合并冲突待解决/);
assert.match(rem.operations ?? '', /git merge maestro\//, 'operations 应含合并步骤');
assert.match(rem.operations ?? '', /web\/app\.js/, 'operations 应含冲突详情');
// 幂等标记写回原任务
assert.equal(s.getTask(t.id).result?.mergeTaskId, rem.id);
// 再次调用 → 复用同一补救任务,不新建
const again = s.ensureMergeRemediationTask(t.id, {
branch: 'maestro/' + t.id, targetBranch: 'main', conflictError: '再次冲突',
});
assert.equal(again.id, rem.id, '已有未结束补救任务 → 复用');
const remCount = s.listTasks(p.id).filter((x) => x.title.includes('合并冲突待解决')).length;
assert.equal(remCount, 1, '不应重复建补救任务');
// 补救任务结束(cancelled)后再调用 → 允许新建
s.transition(rem.id, 'cancelled');
const fresh = s.ensureMergeRemediationTask(t.id, {
branch: 'maestro/' + t.id, targetBranch: 'main', conflictError: '又冲突',
});
assert.notEqual(fresh.id, rem.id, '旧补救任务已结束 → 建新的');
s.close();
});