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>
This commit is contained in:
wangjia
2026-06-13 04:04:41 +08:00
parent bb6186902a
commit c89d6d129c
12 changed files with 347 additions and 31 deletions
+66
View File
@@ -208,3 +208,69 @@ test('容器收口:已拆解 Hard 的子任务全 done → 容器自动 done
assert.equal(s.getTask(root.id).status, 'done'); // 子全 done → 容器自动 done
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();
});