Files
wangjia c89d6d129c feat: 重试与超时策略细化(可配 + 退避)tsk_N5wO3Armums2
## 变更摘要

### 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>
2026-06-13 04:04:41 +08:00

196 lines
8.4 KiB
TypeScript
Raw Permalink 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 { 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 的临时仓库目录,返回 repoPathcaller 负责 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: '子Atier1 也必须 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 → plandesc 合成
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('synctodo.json 不存在 → StoreErrorAPI 层 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 });
});