feat: 合并失败→补救→完成 全程事件轨迹与自动收口 [tsk_BagzbKZNAHXV]

不新增顶层状态,用 events + result 字段记录完整轨迹:
- 新增 merge.failed / merge.remediated 两类事件
- merge 失败时在原任务时间线记 merge.failed 节点(结构化冲突文件 + 补救任务链接)
- 补救任务成功合并后,findMergeOriginTask 反查原任务,remediateOrigin 自动把原任务标 done
  并记 merge.remediated 节点,无需人工 merge:false
- 看板:归档时间线渲染失败/补救节点;结果评审闸显示「合并失败 → 补救任务」横幅

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-13 15:04:39 +08:00
parent f020e15137
commit b1d881a4b4
7 changed files with 149 additions and 8 deletions
+51
View File
@@ -302,6 +302,57 @@ test('合并失败补救:建最高优先级 easy 任务,且幂等不重复
s.close();
});
test('合并失败→补救→完成:事件流完整 + 补救合并后原任务自动 done', () => {
const s = freshStore();
const p = s.createProject({ name: 'rem', repoPath: '/tmp/rem-' + Math.random() });
// 原任务进入 exec_review、带分支结果
const t = s.createTask({ projectId: p.id, title: '原任务', complexity: 'easy', priority: 1 });
s.setOperations(t.id, 'op');
s.transition(t.id, 'queued');
s.transition(t.id, 'executing');
s.setResult(t.id, {
branch: 'maestro/' + t.id, worktree: '/tmp/wt', diffSummary: '', commits: [], prUrl: null,
summary: null, verdict: null, securitySummary: null, securityVerdict: null, mergeTaskId: null,
});
s.transition(t.id, 'exec_review');
// 合并失败 → 建补救任务(带结构化冲突文件)
const rem = s.ensureMergeRemediationTask(t.id, {
branch: 'maestro/' + t.id, targetBranch: 'main',
conflictError: '冲突文件:web/app.js', conflictFiles: ['web/app.js', 'src/x.ts'],
});
// 1) 原任务时间线出现 merge.failed 节点(带冲突文件 + 补救任务链接)
const failed = s.listTaskEvents(t.id).filter((e) => e.type === 'merge.failed');
assert.equal(failed.length, 1, '应记一个合并失败事件');
assert.equal(failed[0].payload.remediationTaskId, rem.id);
assert.deepEqual(failed[0].payload.conflictFiles, ['web/app.js', 'src/x.ts']);
// 2) 反查:补救任务 → 原任务
assert.equal(s.findMergeOriginTask(rem.id)?.id, t.id);
assert.equal(s.findMergeOriginTask('tsk_nope'), null);
// 3) 补救任务跑到 exec_review,合并成功后自动收口原任务
s.transition(rem.id, 'queued');
s.transition(rem.id, 'executing');
s.transition(rem.id, 'exec_review');
s.decide(rem.id, 'accept', 'user'); // 补救任务自身合并(done
const closed = s.remediateOrigin(t.id, rem.id, 'abc123');
assert.equal(closed?.status, 'done', '补救合并后原任务自动 done');
assert.match(closed?.result?.prUrl ?? '', /^merged-via:/, '记录经补救任务并入');
// 4) 原任务时间线出现 merge.remediated 节点
const remediated = s.listTaskEvents(t.id).filter((e) => e.type === 'merge.remediated');
assert.equal(remediated.length, 1, '应记一个补救完成事件');
assert.equal(remediated[0].payload.remediationTaskId, rem.id);
// 幂等:原任务已 done,再次收口不报错、不改状态
const again = s.remediateOrigin(t.id, rem.id, 'abc123');
assert.equal(again?.status, 'done');
assert.equal(s.listTaskEvents(t.id).filter((e) => e.type === 'merge.remediated').length, 1, '不重复记事件');
s.close();
});
test('patchProjectmaxRetries/timeoutMs 可配 + 校验', () => {
const s = freshStore();
const p = s.createProject({ name: 'cfg', repoPath: '/tmp/cfg-' + Math.random() });