From a8f75cf10a2d367d58f282d23bed63a0263c65f5 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 13 Jun 2026 23:27:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(store):=20reconcileInterrupted=20=E8=B7=B3?= =?UTF-8?q?=E8=BF=87=E5=B7=B2=E7=BB=88=E6=80=81=E4=BB=BB=E5=8A=A1,?= =?UTF-8?q?=E9=81=BF=E5=85=8D=20cancelled=E2=86=92failed=20=E8=AE=A9=20dae?= =?UTF-8?q?mon=20=E5=90=AF=E5=8A=A8=E5=B4=A9=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 执行中的任务被 cancel 后仍挂 started run;daemon 重启时 reconcileInterrupted 对它调 failTaskAttempt → transition(cancelled→failed) 非法 → 启动抛错退出(整个 daemon 起不来)。修:终态(cancelled/done)任务只 finishRun 收尾,不再 fail*Attempt。含回归测试(202 全绿)。 Co-Authored-By: Claude Opus 4.8 --- src/store/store.ts | 14 +++++++++++--- test/store.test.ts | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/store/store.ts b/src/store/store.ts index ff4dff8..32bca9c 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -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-adopt,daemon 续 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 }; diff --git a/test/store.test.ts b/test/store.test.ts index 032b183..510e807 100644 --- a/test/store.test.ts +++ b/test/store.test.ts @@ -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() });