Files
maestro/test/store.test.ts
T
wangjia 5a609a8675 feat(retry+decompose): 范围型驳回不重试 + 拆解 prompt 补 scope 铁律
- ingest:复审 reject 时若任务有 scopeFiles 且理由命中越界/范围外/声明范围等标记 → 判为范围型驳回,调 store.failScopeReject 直接转 needs_attention(清退避、不进重试链),因 executor 碰不了范围外文件、重试必然同样被驳回;- store.failScopeReject:补记 failed run 承载原因 + 直转 needs_attention(带单测);- runner 拆解 prompt:加「files 范围铁律」——覆盖全跨层足迹 / 后端能力别塞前端范围 / 宁空勿窄 / 全或空。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 23:32:10 +08:00

536 lines
25 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('patchProject models:持久化 + 读回(字符串/对象形式,非法值丢弃,null 清空)', () => {
const s = freshStore();
const p = s.createProject({ name: 'mdl', repoPath: '/tmp/mdl-' + Math.random() });
assert.equal(p.models, null);
const up = s.patchProject(p.id, {
models: {
executor: { easy: 'claude-sonnet-4-6', hard: 'claude-opus-4-8' },
reviewer: 'claude-fable-5',
// @ts-expect-error 测试非法角色被丢弃
bogus: 'x',
},
});
assert.deepEqual(up.models, {
executor: { easy: 'claude-sonnet-4-6', hard: 'claude-opus-4-8' },
reviewer: 'claude-fable-5',
});
// 读回(走 mapper)一致
assert.deepEqual(s.getProject(p.id)!.models, up.models);
// null 清空
const cleared = s.patchProject(p.id, { models: null });
assert.equal(cleared.models, null);
s.close();
});
test('task scopeFilescreate + patch 持久化与读回,去空白/空数组→null', () => {
const s = freshStore();
const p = s.createProject({ name: 'scp', repoPath: '/tmp/scp-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy', scopeFiles: [' src/** ', '', 'lib/a.ts'] });
assert.deepEqual(t.scopeFiles, ['src/**', 'lib/a.ts']); // 去空白 + 丢空项
assert.deepEqual(s.getTask(t.id)!.scopeFiles, ['src/**', 'lib/a.ts']);
const patched = s.patchTask(t.id, { scopeFiles: ['only.ts'] });
assert.deepEqual(patched.scopeFiles, ['only.ts']);
const emptied = s.patchTask(t.id, { scopeFiles: [] });
assert.equal(emptied.scopeFiles, null); // 空数组 → null(不限范围)
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('markReeval(规则13):executing → needs_attention,收尾 run 为 cancelled,不计执行失败次数', () => {
const s = freshStore();
const p = s.createProject({ name: 'rev', repoPath: '/tmp/rev-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy', scopeFiles: ['src/**'] });
s.setOperations(t.id, 'op');
s.transition(t.id, 'queued');
s.transition(t.id, 'executing');
const run = s.startRun(t.id, 'executor');
const after = s.markReeval(t.id, run.id, '执行前同步发现 main 已改动声明范围内文件');
assert.equal(after.status, 'needs_attention', '应直接转 needs_attention');
assert.equal(s.getRun(run.id)!.status, 'cancelled', 'run 收尾为 cancelled(非 failed');
// 没有任何 executor run 被标 failed(重评估不计失败次数 / 不进重试链)
const failedRuns = s.listRuns(t.id).filter((r) => r.kind === 'executor' && r.status === 'failed');
assert.equal(failedRuns.length, 0, 'markReeval 不应产生 failed 的 executor run');
s.close();
});
test('failScopeReject(范围型驳回):直接 needs_attention、不进重试链、清退避,记一条 failed run 承载原因', () => {
const s = freshStore();
const p = s.createProject({ name: 'scoperej', repoPath: '/tmp/scoperej-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy', scopeFiles: ['design/**'] });
s.setOperations(t.id, 'op');
s.transition(t.id, 'queued');
s.transition(t.id, 'executing');
const after = s.failScopeReject(t.id, '复审因范围不足驳回(停止重试):Scope A entirely undone');
assert.equal(after.status, 'needs_attention', '范围型驳回应直接转 needs_attention(不重试)');
assert.equal(after.nextEligibleAt, null, '应清退避、不进重试链');
// 补记一条 failed executor run 承载原因(供 UI lastRunError 兜底)
const failed = s.listRuns(t.id).filter((r) => r.kind === 'executor' && r.status === 'failed');
assert.equal(failed.length, 1, '应补记一条 failed executor run');
assert.match(failed[0].error ?? '', /范围不足/);
s.close();
});
test('reconcileInterrupted:执行中被 cancel 的任务仍挂 started run → 仅收尾不崩(绝不非法 cancelled→failed', () => {
const s = freshStore();
const p = s.createProject({ name: 'recon', repoPath: '/tmp/recon-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy' });
s.setOperations(t.id, 'op');
s.transition(t.id, 'queued');
s.transition(t.id, 'executing');
const run = s.startRun(t.id, 'executor', { worktree: '/wt', branch: 'maestro/x' });
s.transition(t.id, 'cancelled', { by: 'user' }); // 执行中被取消(合法)
// daemon 重启:worker 判死 → 终态任务仅收尾,绝不 fail*Attempt(否则 cancelled→failed 抛错会让 daemon 启动崩溃)
assert.doesNotThrow(() => s.reconcileInterrupted(() => false));
assert.equal(s.getTask(t.id)!.status, 'cancelled'); // 任务仍 cancelled
assert.equal(s.listRuns(t.id).find((x) => x.id === run.id)!.status, 'failed'); // 残留 run 已收尾
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('deleteTask:删除叶子任务,不影响同项目其他任务', () => {
const s = freshStore();
const p = s.createProject({ name: 'del1', repoPath: '/tmp/del1-' + Math.random() });
const a = s.createTask({ projectId: p.id, title: 'A', complexity: 'easy' });
const b = s.createTask({ projectId: p.id, title: 'B', complexity: 'easy' });
const { deleted } = s.deleteTask(a.id);
assert.equal(deleted, 1);
assert.equal(s.getTask(a.id), null); // 已删
assert.ok(s.getTask(b.id)); // 不受影响
s.close();
});
test('deleteTask:级联删除带子任务的父任务', () => {
const s = freshStore();
const p = s.createProject({ name: 'del2', repoPath: '/tmp/del2-' + Math.random() });
const root = s.createTask({ projectId: p.id, title: 'root', complexity: 'hard' });
s.setPlan(root.id, '拆'); s.transition(root.id, 'plan_review'); s.decide(root.id, 'accept', 'user');
const c1 = s.createTask({ projectId: p.id, parentId: root.id, title: 'c1', complexity: 'easy' });
const c2 = s.createTask({ projectId: p.id, parentId: root.id, title: 'c2', complexity: 'easy' });
// c1 下再建孙子
const gc = s.createTask({ projectId: p.id, parentId: c1.id, title: 'gc', complexity: 'easy' });
const { deleted } = s.deleteTask(root.id);
assert.equal(deleted, 4); // root + c1 + c2 + gc
assert.equal(s.getTask(root.id), null);
assert.equal(s.getTask(c1.id), null);
assert.equal(s.getTask(c2.id), null);
assert.equal(s.getTask(gc.id), null);
s.close();
});
test('deleteTask:不存在的任务抛 StoreError', () => {
const s = freshStore();
assert.throws(() => s.deleteTask('tsk_nonexistent'), (e: Error) => e.message.includes('任务不存在'));
s.close();
});
test('transition → cancelled:合法的状态流转(init/ready/executing 等)', () => {
const s = freshStore();
const p = s.createProject({ name: 'can', repoPath: '/tmp/can-' + Math.random() });
const t1 = s.createTask({ projectId: p.id, title: 'init task', complexity: 'easy' });
assert.equal(t1.status, 'ready');
const cancelled = s.transition(t1.id, 'cancelled', { by: 'user' });
assert.equal(cancelled.status, 'cancelled');
// 已取消的任务不可再次流转
assert.throws(() => s.transition(t1.id, 'ready'), (e: Error) => e.message.includes('非法状态流转'));
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();
});
test('合并失败→补救→完成:事件流完整 + 补救合并后原任务自动 done', () => {
const s = freshStore();
const p = s.createProject({ name: 'rem', repoPath: '/tmp/rem-' + Math.random() });
// 原任务进入 exec_review、带分支结果
const t = s.createTask({ projectId: p.id, title: '原任务', 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', conflictFiles: ['web/app.js', 'src/x.ts'],
});
// 1) 原任务时间线出现 merge.failed 节点(带冲突文件 + 补救任务链接)
const failed = s.listTaskEvents(t.id).filter((e) => e.type === 'merge.failed');
assert.equal(failed.length, 1, '应记一个合并失败事件');
assert.equal(failed[0].payload.remediationTaskId, rem.id);
assert.deepEqual(failed[0].payload.conflictFiles, ['web/app.js', 'src/x.ts']);
// 2) 反查:补救任务 → 原任务
assert.equal(s.findMergeOriginTask(rem.id)?.id, t.id);
assert.equal(s.findMergeOriginTask('tsk_nope'), null);
// 3) 补救任务跑到 exec_review,合并成功后自动收口原任务
s.transition(rem.id, 'queued');
s.transition(rem.id, 'executing');
s.transition(rem.id, 'exec_review');
s.decide(rem.id, 'accept', 'user'); // 补救任务自身合并(done
const closed = s.remediateOrigin(t.id, rem.id, 'abc123');
assert.equal(closed?.status, 'done', '补救合并后原任务自动 done');
assert.match(closed?.result?.prUrl ?? '', /^merged-via:/, '记录经补救任务并入');
// 4) 原任务时间线出现 merge.remediated 节点
const remediated = s.listTaskEvents(t.id).filter((e) => e.type === 'merge.remediated');
assert.equal(remediated.length, 1, '应记一个补救完成事件');
assert.equal(remediated[0].payload.remediationTaskId, rem.id);
// 幂等:原任务已 done,再次收口不报错、不改状态
const again = s.remediateOrigin(t.id, rem.id, 'abc123');
assert.equal(again?.status, 'done');
assert.equal(s.listTaskEvents(t.id).filter((e) => e.type === 'merge.remediated').length, 1, '不重复记事件');
s.close();
});
test('patchProjectmaxRetries/timeoutMs 可配 + 校验', () => {
const s = freshStore();
const p = s.createProject({ name: 'cfg', repoPath: '/tmp/cfg-' + Math.random() });
// 默认值
assert.equal(p.maxRetries, 2);
assert.equal(p.timeoutMs, 1_800_000);
// 更新 maxRetries + timeoutMs
const updated = s.patchProject(p.id, { maxRetries: 5, timeoutMs: 60_000 });
assert.equal(updated.maxRetries, 5);
assert.equal(updated.timeoutMs, 60_000);
// 校验:maxRetries 必须 >=0 的整数
assert.throws(() => s.patchProject(p.id, { maxRetries: -1 }), /maxRetries/);
assert.throws(() => s.patchProject(p.id, { maxRetries: 1.5 }), /maxRetries/);
// 校验:timeoutMs 必须 >=1000
assert.throws(() => s.patchProject(p.id, { timeoutMs: 500 }), /timeoutMs/);
s.close();
});
test('createProjectmaxRetries/timeoutMs 自定义初值', () => {
const s = freshStore();
const p = s.createProject({ name: 'custom', repoPath: '/tmp/custom-' + Math.random(), maxRetries: 0, timeoutMs: 120_000 });
assert.equal(p.maxRetries, 0);
assert.equal(p.timeoutMs, 120_000);
s.close();
});
test('requeueTaskneeds_attention → queued,重置 retryBaseline', () => {
const s = freshStore();
const p = s.createProject({ name: 'rq', repoPath: '/tmp/rq-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'flaky', complexity: 'easy' });
// 推进到 needs_attention(模拟 3 次失败 run
s.transition(t.id, 'queued');
s.transition(t.id, 'executing');
s.transition(t.id, 'failed');
s.transition(t.id, 'queued');
s.transition(t.id, 'executing');
s.transition(t.id, 'failed');
s.transition(t.id, 'queued');
s.transition(t.id, 'executing');
s.transition(t.id, 'failed');
s.transition(t.id, 'needs_attention');
// 模拟 3 条 failed executor run
const r1 = s.startRun(t.id, 'executor');
s.finishRun(r1.id, 'failed', { error: 'boom1' });
const r2 = s.startRun(t.id, 'executor');
s.finishRun(r2.id, 'failed', { error: 'boom2' });
const r3 = s.startRun(t.id, 'executor');
s.finishRun(r3.id, 'failed', { error: 'boom3' });
assert.equal(s.getTask(t.id)!.retryBaseline, 0);
// 一键重投
const requeued = s.requeueTask(t.id);
assert.equal(requeued.status, 'queued');
assert.equal(requeued.retryBaseline, 3); // 基线设为当前失败 run 数
// 只有 needs_attention 状态才可重投
assert.throws(() => s.requeueTask(t.id), /needs_attention/);
s.close();
});
test('deleteProject:连带删 tasks/runs/approvals/events,不可恢复', () => {
const s = freshStore();
const p = s.createProject({ name: 'del', repoPath: '/tmp/del-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy' });
s.setOperations(t.id, 'op');
const r = s.startRun(t.id, 'executor');
s.finishRun(r.id, 'succeeded');
assert.ok(s.listEvents(p.id).length > 0, '删前有事件');
assert.ok(s.listTasks(p.id).length > 0, '删前有任务');
assert.ok(s.listRuns(t.id).length > 0, '删前有 run');
s.deleteProject(p.id);
assert.equal(s.getProject(p.id), null, '项目已删');
assert.equal(s.listTasks(p.id).length, 0, 'tasks 连带清');
assert.equal(s.listRuns(t.id).length, 0, 'runs 连带清(经 tasks cascade');
assert.equal(s.listEvents(p.id).length, 0, 'events 连带清');
assert.throws(() => s.deleteProject(p.id), /不存在/, '重复删 → 报错');
s.close();
});