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>
196 lines
8.4 KiB
TypeScript
196 lines
8.4 KiB
TypeScript
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import Database from 'better-sqlite3';
|
||
import { Store, StoreError, openDb } from '../src/store/index.js';
|
||
import { syncProject, todoJsonPath, hasTodoJson } from '../src/sync/todo-sync.js';
|
||
|
||
// ---------- fixtures ----------
|
||
|
||
/** 造一个带 todo/todo.json 的临时仓库目录,返回 repoPath(caller 负责 rmSync) */
|
||
function makeRepo(items: unknown[]): string {
|
||
const repo = mkdtempSync(join(tmpdir(), 'maestro-sync-'));
|
||
mkdirSync(join(repo, 'todo'), { recursive: true });
|
||
writeFileSync(todoJsonPath(repo), JSON.stringify({ meta: { title: 't' }, seq: items.length, items }, null, 2));
|
||
return repo;
|
||
}
|
||
|
||
const FIXTURE_ITEMS = [
|
||
{
|
||
id: 1, title: '硬任务带子任务', tier: 1, level: 'high', status: 'doing',
|
||
desc: '描述文本', tags: ['后端'],
|
||
subtasks: [
|
||
{ sid: '1A', title: '子A(tier1 也必须 easy)', tier: 1, deps: [], status: 'open' },
|
||
{ sid: '1B', title: '子B', tier: 2, deps: ['1A'], status: 'done' },
|
||
],
|
||
gate: { kind: 'plan', note: '方案说明', ref: 'doc/x.md', approval: 'pending' },
|
||
},
|
||
{ id: 2, title: '中任务已验收', tier: 2, level: 'mid', status: 'accepted', version: 'v1.0.0' },
|
||
{ id: 3, title: '易任务待验收', tier: 3, level: 'low', status: 'done' },
|
||
{ id: 4, title: '缺 tier 默认 medium', status: 'open' },
|
||
];
|
||
|
||
// ---------- 同步引擎 ----------
|
||
|
||
test('sync:首次导入计数 / subs 一律 easy / 产出字段 / source_ref 映射', () => {
|
||
const repo = makeRepo(FIXTURE_ITEMS);
|
||
const s = new Store(':memory:');
|
||
const events: string[] = [];
|
||
s.subscribe((e) => events.push(e.type));
|
||
const p = s.createProject({ name: 'sync1', repoPath: repo });
|
||
|
||
const r = syncProject(s, p.id);
|
||
assert.equal(r.created, 6); // 4 items + 2 subs
|
||
assert.equal(r.skipped, 0);
|
||
// 旧侧已完成:#2(accepted) + #3(done) + 子1B(done) = 3
|
||
assert.equal(r.doneAdvanced, 3);
|
||
assert.ok(r.lastSyncAt);
|
||
// 缺 tier 警告(#4)
|
||
assert.ok(r.warnings.some((w) => w.includes('缺少有效 tier')));
|
||
// project.synced 事件已广播
|
||
assert.ok(events.includes('project.synced'));
|
||
// last_sync_at 已落库
|
||
assert.equal(s.getProject(p.id)!.lastSyncAt, r.lastSyncAt);
|
||
|
||
// subs 一律 easy(即使旧 tier=1)
|
||
const subA = s.getTaskBySourceRef(p.id, 'todo:1/1A');
|
||
const subB = s.getTaskBySourceRef(p.id, 'todo:1/1B');
|
||
assert.equal(subA?.complexity, 'easy');
|
||
assert.equal(subB?.complexity, 'easy');
|
||
assert.equal(subB?.status, 'done'); // 旧 done → 推到 done
|
||
assert.equal(subA?.status, 'ready'); // 旧 open → easy 初始态
|
||
assert.equal(subB?.deps[0], subA?.id); // sid 依赖 → 新任务 id
|
||
|
||
// 顶层复杂度映射 + 产出字段
|
||
const t1 = s.getTaskBySourceRef(p.id, 'todo:1');
|
||
const t2 = s.getTaskBySourceRef(p.id, 'todo:2');
|
||
const t3 = s.getTaskBySourceRef(p.id, 'todo:3');
|
||
const t4 = s.getTaskBySourceRef(p.id, 'todo:4');
|
||
assert.equal(t1?.complexity, 'hard');
|
||
assert.equal(t2?.complexity, 'medium');
|
||
assert.equal(t3?.complexity, 'easy');
|
||
assert.equal(t4?.complexity, 'medium'); // 缺省 medium
|
||
assert.ok(t1?.plan?.includes('描述文本')); // hard → plan,desc 合成
|
||
assert.ok(t1?.plan?.includes('方案说明')); // gate note 合成
|
||
assert.equal(t2?.status, 'done');
|
||
assert.equal(t3?.status, 'done');
|
||
assert.equal(t1?.status, 'analyzing'); // doing → 初始态,不推进
|
||
|
||
s.close();
|
||
rmSync(repo, { recursive: true, force: true });
|
||
});
|
||
|
||
test('sync:幂等——第二次 created=0、全部 skipped、doneAdvanced=0', () => {
|
||
const repo = makeRepo(FIXTURE_ITEMS);
|
||
const s = new Store(':memory:');
|
||
const p = s.createProject({ name: 'sync2', repoPath: repo });
|
||
|
||
const first = syncProject(s, p.id);
|
||
assert.equal(first.created, 6);
|
||
const second = syncProject(s, p.id);
|
||
assert.equal(second.created, 0);
|
||
assert.equal(second.skipped, 6);
|
||
assert.equal(second.doneAdvanced, 0);
|
||
|
||
s.close();
|
||
rmSync(repo, { recursive: true, force: true });
|
||
});
|
||
|
||
test('sync:增量——新 item 只建新的;源里消失的记 warning 不删', () => {
|
||
const repo = makeRepo(FIXTURE_ITEMS);
|
||
const s = new Store(':memory:');
|
||
const p = s.createProject({ name: 'sync3', repoPath: repo });
|
||
syncProject(s, p.id);
|
||
|
||
// 源变化:删掉 #4,新增 #5 + 给 #1 加一个子任务
|
||
const items = [
|
||
{ ...FIXTURE_ITEMS[0], subtasks: [...(FIXTURE_ITEMS[0] as { subtasks: unknown[] }).subtasks, { sid: '1C', title: '新子C', tier: 1, status: 'open' }] },
|
||
FIXTURE_ITEMS[1], FIXTURE_ITEMS[2],
|
||
{ id: 5, title: '新增任务', tier: 3, status: 'open' },
|
||
];
|
||
writeFileSync(todoJsonPath(repo), JSON.stringify({ items }));
|
||
|
||
const r = syncProject(s, p.id);
|
||
assert.equal(r.created, 2); // 1C + #5
|
||
assert.ok(r.warnings.some((w) => w.includes('todo:4') && w.includes('保留')));
|
||
assert.ok(s.getTaskBySourceRef(p.id, 'todo:4')); // 没删
|
||
assert.equal(s.getTaskBySourceRef(p.id, 'todo:1/1C')?.complexity, 'easy');
|
||
|
||
s.close();
|
||
rmSync(repo, { recursive: true, force: true });
|
||
});
|
||
|
||
test('sync:todo.json 不存在 → StoreError(API 层 400),消息含路径', () => {
|
||
const repo = mkdtempSync(join(tmpdir(), 'maestro-norepo-'));
|
||
const s = new Store(':memory:');
|
||
const p = s.createProject({ name: 'sync4', repoPath: repo });
|
||
assert.equal(hasTodoJson(repo), false);
|
||
assert.throws(
|
||
() => syncProject(s, p.id),
|
||
(e: unknown) => e instanceof StoreError && (e as Error).message === `未找到 todo/todo.json:${todoJsonPath(repo)}`,
|
||
);
|
||
s.close();
|
||
rmSync(repo, { recursive: true, force: true });
|
||
});
|
||
|
||
// ---------- 旧库轻量迁移 ----------
|
||
|
||
test('迁移:旧版库(无 source_ref/last_sync_at)打开后补列且数据完好', () => {
|
||
const dir = mkdtempSync(join(tmpdir(), 'maestro-mig-'));
|
||
const file = join(dir, 'old.sqlite');
|
||
|
||
// 用 v0.1 schema 手工造旧库(不含新列)
|
||
const old = new Database(file);
|
||
old.exec(`
|
||
CREATE TABLE projects (
|
||
id TEXT PRIMARY KEY, name TEXT NOT NULL, repo_path TEXT NOT NULL UNIQUE,
|
||
default_branch TEXT NOT NULL DEFAULT 'main', verify_cmd TEXT,
|
||
autonomy TEXT NOT NULL DEFAULT 'manual', model TEXT,
|
||
concurrency INTEGER NOT NULL DEFAULT 1, status TEXT NOT NULL DEFAULT 'active',
|
||
created_at TEXT NOT NULL
|
||
);
|
||
CREATE TABLE tasks (
|
||
id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT, depth INTEGER NOT NULL DEFAULT 1,
|
||
title TEXT NOT NULL, complexity TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'init',
|
||
priority INTEGER NOT NULL DEFAULT 0, deps TEXT NOT NULL DEFAULT '[]',
|
||
plan TEXT, spec TEXT, operations TEXT, result TEXT, assignee TEXT,
|
||
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
||
);
|
||
INSERT INTO projects (id,name,repo_path,created_at) VALUES ('prj_old','旧项目','/tmp/old-repo','2026-01-01T00:00:00Z');
|
||
INSERT INTO tasks (id,project_id,title,complexity,status,created_at,updated_at)
|
||
VALUES ('tsk_old','prj_old','旧任务','easy','ready','2026-01-01T00:00:00Z','2026-01-01T00:00:00Z');
|
||
`);
|
||
old.close();
|
||
|
||
// 新代码打开 → 自动补列
|
||
const db = openDb(file);
|
||
const projCols = (db.prepare(`PRAGMA table_info(projects)`).all() as Array<{ name: string }>).map((c) => c.name);
|
||
const taskCols = (db.prepare(`PRAGMA table_info(tasks)`).all() as Array<{ name: string }>).map((c) => c.name);
|
||
assert.ok(projCols.includes('last_sync_at'));
|
||
assert.ok(projCols.includes('max_retries'));
|
||
assert.ok(projCols.includes('timeout_ms'));
|
||
assert.ok(taskCols.includes('source_ref'));
|
||
assert.ok(taskCols.includes('retry_baseline'));
|
||
db.close();
|
||
|
||
// Store 能正常读旧数据,新字段使用默认值
|
||
const s = new Store(file);
|
||
const projects = s.listProjects();
|
||
assert.equal(projects.length, 1);
|
||
assert.equal(projects[0].name, '旧项目');
|
||
assert.equal(projects[0].lastSyncAt, null);
|
||
assert.equal(projects[0].maxRetries, 2); // 默认值
|
||
assert.equal(projects[0].timeoutMs, 1_800_000); // 默认 30min
|
||
const t = s.getTask('tsk_old');
|
||
assert.equal(t?.title, '旧任务');
|
||
assert.equal(t?.retryBaseline, 0); // 默认基线
|
||
// 新方法在迁移后的旧库上可用
|
||
s.setSourceRef('tsk_old', 'todo:99');
|
||
assert.equal(s.getTaskBySourceRef('prj_old', 'todo:99')?.id, 'tsk_old');
|
||
s.close();
|
||
|
||
rmSync(dir, { recursive: true, force: true });
|
||
});
|