fix(store): reconcileInterrupted 跳过已终态任务,避免 cancelled→failed 让 daemon 启动崩溃

执行中的任务被 cancel 后仍挂 started run;daemon 重启时 reconcileInterrupted 对它调 failTaskAttempt → transition(cancelled→failed) 非法 → 启动抛错退出(整个 daemon 起不来)。修:终态(cancelled/done)任务只 finishRun 收尾,不再 fail*Attempt。含回归测试(202 全绿)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-13 23:27:53 +08:00
parent d1862efb4b
commit a8f75cf10a
2 changed files with 27 additions and 3 deletions
+11 -3
View File
@@ -749,9 +749,17 @@ export class Store {
let readopted = 0, reclaimed = 0;
for (const { task, run } of this.liveRunsWithTask()) {
if (isAlive(run)) { readopted++; continue; } // worker 仍活 → re-adoptdaemon 续 ingest
if (run.kind === 'executor') this.failTaskAttempt(task.id, run.id, 'daemon 重启时发现 worker 已退出');
else if (run.kind === 'planner') this.failPlanAttempt(task.id, run.id, 'daemon 重启时发现 planner worker 已退出');
else this.finishRun(run.id, 'failed', { error: 'daemon 重启中断' }); // 残留复审 run:仅收尾
// 任务已终态(如执行中被 cancel):只收尾残留 run,绝不再 fail*Attempt——
// cancelled→failed 等非法流转会在 reconcile 中抛错、让 daemon 启动直接崩溃。
if (task.status === 'cancelled' || task.status === 'done') {
this.finishRun(run.id, 'failed', { error: 'daemon 重启时发现 worker 已退出(任务已终态,仅收尾不改任务状态)' });
} else if (run.kind === 'executor') {
this.failTaskAttempt(task.id, run.id, 'daemon 重启时发现 worker 已退出');
} else if (run.kind === 'planner') {
this.failPlanAttempt(task.id, run.id, 'daemon 重启时发现 planner worker 已退出');
} else {
this.finishRun(run.id, 'failed', { error: 'daemon 重启中断' }); // 残留复审 run:仅收尾
}
reclaimed++;
}
return { readopted, reclaimed };
+16
View File
@@ -103,6 +103,22 @@ test('exec_review 结果闸:accept → done', () => {
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() });