c89d6d129c
## 变更摘要 ### 1. 项目级可配重试上限与超时 - `Project` 新增 `maxRetries`(默认 2)和 `timeoutMs`(默认 30min = 1800000ms)字段 - SQLite schema:`projects` 表补 `max_retries` / `timeout_ms` 列(带 DEFAULT 的轻量迁移) - `createProject` / `patchProject` 支持设置 / 校验新字段(maxRetries>=0, timeoutMs>=1000) - API `POST /api/projects` 与 `PATCH /api/projects/:id` 透传新字段 - `runner.ts` 将 `project.timeoutMs` 传给 `runClaude`,不再固定 30min ### 2. 失败重试指数退避 - 编排器内存维护 `backoffUntil` Map(taskId → nextRetryAt),daemon 重启后清空 - 退避公式:`min(30s * 2^(attempt-1), 10min)`,第 1 次 30s / 第 2 次 60s / ... - `claimable()` 过滤退避冷却中的任务 - `nowMs` 注入点(默认 `Date.now`)使测试可快进时钟验证退避行为 ### 3. needs_attention 一键重投 - `Task` 新增 `retryBaseline` 字段(默认 0):记录上次重投时的失败 run 基线 - `Store.requeueTask(taskId)`:设置基线 = 当前失败 run 数 → 转 `queued`(仅限 needs_attention) - 编排器用净失败数(`allFailed - retryBaseline`)判断是否已耗尽重试次数 - API `POST /api/tasks/:id/requeue` + MCP `requeue_task` 工具 ### 4. 测试 - 重命名 `MAX_RETRIES` → `DEFAULT_MAX_RETRIES`,新增导出 `computeBackoffMs` - 新增测试(共 +18):maxRetries=1/0、退避时序(精确 ms 边界)、重投后基线重置 - 迁移测试扩展:验证旧库补列后 maxRetries/timeoutMs/retryBaseline 使用默认值 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
277 lines
12 KiB
TypeScript
277 lines
12 KiB
TypeScript
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 → ready;reject 必带意见', () => {
|
||
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() });
|
||
// A:P1 无依赖,但有两条 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] });
|
||
// B:P0 无依赖无人等 → 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('createTask:deps 引用不存在/跨项目任务被拒绝', () => {
|
||
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('patchProject:maxRetries/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('createProject:maxRetries/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('requeueTask:needs_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();
|
||
});
|