From b1d881a4b4e45fe1ed54500d6fd4455fa2edcd54 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 13 Jun 2026 15:04:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=90=88=E5=B9=B6=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E2=86=92=E8=A1=A5=E6=95=91=E2=86=92=E5=AE=8C=E6=88=90=20?= =?UTF-8?q?=E5=85=A8=E7=A8=8B=E4=BA=8B=E4=BB=B6=E8=BD=A8=E8=BF=B9=E4=B8=8E?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=94=B6=E5=8F=A3=20[tsk=5FBagzbKZNAHXV]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 不新增顶层状态,用 events + result 字段记录完整轨迹: - 新增 merge.failed / merge.remediated 两类事件 - merge 失败时在原任务时间线记 merge.failed 节点(结构化冲突文件 + 补救任务链接) - 补救任务成功合并后,findMergeOriginTask 反查原任务,remediateOrigin 自动把原任务标 done 并记 merge.remediated 节点,无需人工 merge:false - 看板:归档时间线渲染失败/补救节点;结果评审闸显示「合并失败 → 补救任务」横幅 Co-Authored-By: Claude Opus 4.8 --- src/api/server.ts | 12 +++++++++- src/executor/merge.ts | 11 ++++++---- src/model/types.ts | 1 + src/store/store.ts | 47 +++++++++++++++++++++++++++++++++++++-- test/store.test.ts | 51 +++++++++++++++++++++++++++++++++++++++++++ web/app.js | 27 ++++++++++++++++++++++- web/style.css | 8 +++++++ 7 files changed, 149 insertions(+), 8 deletions(-) diff --git a/src/api/server.ts b/src/api/server.ts index a09829f..898d537 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -324,7 +324,8 @@ export function buildServer(opts: ApiOptions): FastifyInstance { if (!mr.ok) { // 自动合并失败(多为冲突)→ 建/复用最高优先级补救任务来完成合并,原任务留在审核闸 const rem = store.ensureMergeRemediationTask(task.id, { - branch, targetBranch: project.defaultBranch, conflictError: mr.error ?? '未知冲突', + branch, targetBranch: project.defaultBranch, + conflictError: mr.error ?? '未知冲突', conflictFiles: mr.conflictFiles, }); throw new StoreError(`合并失败:${mr.error}(原任务保留在审核闸;已建最高优先级补救任务 ${rem.id}「${rem.title}」来完成合并)`); } @@ -332,6 +333,15 @@ export function buildServer(opts: ApiOptions): FastifyInstance { store.decide(id, 'accept', b.actor ?? 'user', b.reason ?? null); // 合并产物记录:复用 prUrl 字段写 merged: store.setResult(id, { ...task.result, prUrl: `merged:${mr.mergeCommit}` }); + // 补救完成自动收口:若本任务是某原任务的「合并冲突补救」任务,合并成功后把原任务也标 done + const origin = store.findMergeOriginTask(id); + if (origin) { + try { + store.remediateOrigin(origin.id, id, mr.mergeCommit); + } catch (e) { + app.log.error(`补救任务 ${id} 合并后收口原任务 ${origin.id} 失败(不影响本任务结果):${(e as Error).message}`); + } + } // 异步回收:执行 worktree + 已合并的任务分支(失败只记日志,不影响响应) void (async () => { try { diff --git a/src/executor/merge.ts b/src/executor/merge.ts index e4e8533..5fa3b2b 100644 --- a/src/executor/merge.ts +++ b/src/executor/merge.ts @@ -6,6 +6,7 @@ export interface MergeResult { ok: boolean; mergeCommit?: string; // 成功时为合并后 defaultBranch 的 HEAD(重复合并时为当前 HEAD,不新建提交) error?: string; + conflictFiles?: string[]; // 失败为冲突时的冲突文件列表(结构化,供事件/时间线展示) } /** 临时合并 worktree 目录(确定性):/worktrees/_merge// */ @@ -57,8 +58,9 @@ async function mergeInPrimaryCheckout( let conflicted = ''; try { conflicted = (await git(repoPath, ['diff', '--name-only', '--diff-filter=U'])).trim(); } catch { /* ignore */ } await git(repoPath, ['merge', '--abort']).catch(() => undefined); - const files = conflicted ? `;冲突文件:${conflicted.split('\n').join('、')}` : ''; - return { ok: false, error: `${(e as Error).message}${files}` }; + const conflictFiles = conflicted ? conflicted.split('\n') : []; + const files = conflictFiles.length ? `;冲突文件:${conflictFiles.join('、')}` : ''; + return { ok: false, error: `${(e as Error).message}${files}`, conflictFiles }; } } @@ -116,8 +118,9 @@ export async function mergeBranch( conflicted = (await git(dir, ['diff', '--name-only', '--diff-filter=U'])).trim(); } catch { /* 收集失败不影响报错 */ } await git(dir, ['merge', '--abort']).catch(() => undefined); - const files = conflicted ? `;冲突文件:${conflicted.split('\n').join('、')}` : ''; - return { ok: false, error: `${(e as Error).message}${files}` }; + const conflictFiles = conflicted ? conflicted.split('\n') : []; + const files = conflictFiles.length ? `;冲突文件:${conflictFiles.join('、')}` : ''; + return { ok: false, error: `${(e as Error).message}${files}`, conflictFiles }; } finally { // 4) 无论成败都清理临时 worktree await git(repoPath, ['worktree', 'remove', '--force', dir]).catch(() => undefined); diff --git a/src/model/types.ts b/src/model/types.ts index 20a694f..b9e1305 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -98,6 +98,7 @@ export type EventType = | 'task.created' | 'task.updated' | 'status.changed' | 'approval.requested' | 'approval.granted' | 'approval.rejected' | 'run.started' | 'run.finished' + | 'merge.failed' | 'merge.remediated' | 'project.synced'; export interface Event { diff --git a/src/store/store.ts b/src/store/store.ts index c5e899e..26cac49 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -298,7 +298,7 @@ export class Store { */ ensureMergeRemediationTask( taskId: string, - opts: { branch: string; targetBranch: string; conflictError: string }, + opts: { branch: string; targetBranch: string; conflictError: string; conflictFiles?: string[] }, ): Task { const orig = this.getTask(taskId); if (!orig) throw new StoreError(`任务不存在: ${taskId}`); @@ -306,7 +306,14 @@ export class Store { const existingId = orig.result?.mergeTaskId; if (existingId) { const existing = this.getTask(existingId); - if (existing && existing.status !== 'done' && existing.status !== 'cancelled') return existing; + if (existing && existing.status !== 'done' && existing.status !== 'cancelled') { + // 复用既有补救任务:仍记一次「合并失败」节点(每次失败的合并尝试都是真实事件) + this.emit(orig.projectId, orig.id, 'merge.failed', { + remediationTaskId: existing.id, branch: opts.branch, targetBranch: opts.targetBranch, + conflictFiles: opts.conflictFiles ?? [], error: opts.conflictError, reused: true, + }); + return existing; + } } const rem = this.createTask({ @@ -331,9 +338,45 @@ export class Store { // 幂等标记写回原任务 result(保留原有结果字段) if (orig.result) this.setResult(orig.id, { ...orig.result, mergeTaskId: rem.id }); this.emit(orig.projectId, rem.id, 'task.updated', { field: 'merge-remediation', forTask: orig.id }); + // 在原任务时间线上记一个「合并失败 → 补救任务」节点 + this.emit(orig.projectId, orig.id, 'merge.failed', { + remediationTaskId: rem.id, branch: opts.branch, targetBranch: opts.targetBranch, + conflictFiles: opts.conflictFiles ?? [], error: opts.conflictError, + }); return this.getTask(rem.id)!; } + /** + * 反查某补救任务对应的原任务(其 result.mergeTaskId 指向该补救任务)。 + * 用于补救任务成功合并后自动收口原任务。无 → null。 + */ + findMergeOriginTask(remediationTaskId: string): Task | null { + const row = this.db.prepare( + `SELECT * FROM tasks WHERE json_extract(result, '$.mergeTaskId') = ? LIMIT 1`, + ).get(remediationTaskId) as TaskRow | undefined; + return row ? this.getTask(row.id)! : null; + } + + /** + * 补救完成自动收口:补救任务成功合并到默认分支后,把原任务标 done(原任务改动已随补救任务并入), + * 在原任务时间线追加 merge.remediated 事件「补救完成 → 完成」,无需人工对原任务 merge:false。 + * 原任务须仍在 exec_review(合并闸);否则不动(已被人工处理 / 已结束)。 + */ + remediateOrigin(originId: string, remediationTaskId: string, mergeCommit?: string): Task | null { + const row = this.getTaskRow(originId); + if (!row) return null; + if (row.status !== 'exec_review') return this.getTask(originId)!; // 已被人工处理 / 非合并闸 → 不动 + // 合并产物记录:复用 prUrl 字段标记「经补救任务并入」 + const cur = this.getTask(originId)!; + if (cur.result) { + this.setResult(originId, { ...cur.result, prUrl: `merged-via:${remediationTaskId}${mergeCommit ? ':' + mergeCommit : ''}` }); + } + this.emit(row.project_id, originId, 'merge.remediated', { remediationTaskId, mergeCommit: mergeCommit ?? null }); + // exec_review → done(合法流转):触发 afterDone 放行依赖 / 容器收口 + this.transition(originId, 'done', { by: 'merge-remediation', auto: 'merge-remediated', remediationTaskId }); + return this.getTask(originId)!; + } + setResult(taskId: string, result: TaskResult): Task { const row = this.getTaskRow(taskId); if (!row) throw new StoreError(`任务不存在: ${taskId}`); diff --git a/test/store.test.ts b/test/store.test.ts index 67c096c..032b183 100644 --- a/test/store.test.ts +++ b/test/store.test.ts @@ -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('patchProject:maxRetries/timeoutMs 可配 + 校验', () => { const s = freshStore(); const p = s.createProject({ name: 'cfg', repoPath: '/tmp/cfg-' + Math.random() }); diff --git a/web/app.js b/web/app.js index 35dc559..d87263d 100644 --- a/web/app.js +++ b/web/app.js @@ -25,12 +25,14 @@ const EVENT_LABEL = { 'task.created': '任务创建', 'task.updated': '任务更新', 'status.changed': '状态变更', 'approval.requested': '请求审批', 'approval.granted': '审批通过', 'approval.rejected': '审批驳回', 'run.started': '运行开始', 'run.finished': '运行结束', 'project.synced': 'todo 同步', + 'merge.failed': '合并失败', 'merge.remediated': '补救完成 → 完成', }; const EVENT_CLASS = { 'task.created': 'ev-created', 'task.updated': 'ev-updated', 'status.changed': 'ev-status', 'approval.requested': 'ev-gatewait', 'approval.granted': 'ev-approve', 'approval.rejected': 'ev-rejected', 'run.started': 'ev-run', 'run.finished': 'ev-run', 'project.synced': 'ev-created', + 'merge.failed': 'ev-rejected', 'merge.remediated': 'ev-approve', }; const AUTONOMY_LABEL = { manual: '手动', 'auto-easy': '自动执行 Easy', 'auto-approved': '自动执行已批准', @@ -458,6 +460,14 @@ function gateDocs(t) { if (gate === 'exec') { if (t.result) { const r = t.result; + if (r.mergeTaskId) { // 合并失败:已建补救任务在解冲突,原任务停在审核闸 + const rem = S.tasks.find((x) => x.id === r.mergeTaskId); + const remDone = rem && (rem.status === 'done' || rem.status === 'cancelled'); + blocks.push(`
合并状态
+
⚠️ 自动合并失败(冲突)→ 已建最高优先级补救任务 + ${rem ? `「${esc(rem.title)}」(${STATUS_LABEL[rem.status] || esc(rem.status)})` : `${esc(r.mergeTaskId)}`} + ${remDone ? '' : '
补救任务解决冲突并合并后,本任务将自动标记完成,无需手动「仅通过」。'}
`); + } blocks.push(reviewReportBlocks(r)); // CODE REVIEW + 安全审计 两节(各带 verdict 徽章),置于 DIFF 摘要上方 blocks.push(`
RESULT · 执行结果
@@ -643,7 +653,8 @@ function tlActor(p) { if (p.by) who.push(p.by === 'orchestrator' ? '编排器' : p.by); if (p.actor) who.push(p.actor === 'importer' ? '导入器' : p.actor); if (p.auto) who.push({ 'deps-met': '依赖满足自动放行', 'deps-reconcile': '启动对账', 'deps-unmet': '依赖未满足落位', - 'children-done': '子任务全完成自动收口', 'import-done': '导入历史完成', 'daemon-restart': '重启恢复' }[p.auto] || p.auto); + 'children-done': '子任务全完成自动收口', 'import-done': '导入历史完成', 'daemon-restart': '重启恢复', + 'merge-remediated': '补救完成自动收口' }[p.auto] || p.auto); return who.join(' · '); } @@ -721,6 +732,15 @@ function renderArchiveModal(t, runs, events) { if (e.type === 'approval.rejected') text = `审批驳回(${GATE_LABEL[p.gate] || p.gate})${p.reason ? ':' + p.reason : ''}`; if (e.type === 'run.started') text = `run 开始(${RUN_KIND[p.kind] || p.kind || ''})`; if (e.type === 'run.finished') text = `run 结束(${RUN_ST[p.status] || p.status || ''})`; + if (e.type === 'merge.failed') { + const files = (p.conflictFiles && p.conflictFiles.length) ? `(冲突文件:${p.conflictFiles.join('、')})` : ''; + const remT = p.remediationTaskId ? (byId.get(p.remediationTaskId)?.title || p.remediationTaskId) : ''; + text = `合并失败${files}${remT ? ` → 补救任务「${remT}」` : ''}`; + } + if (e.type === 'merge.remediated') { + const remT = p.remediationTaskId ? (byId.get(p.remediationTaskId)?.title || p.remediationTaskId) : ''; + text = `补救完成 → 完成${remT ? `(补救任务「${remT}」已合并)` : ''}`; + } const who = tlActor(p); return `
${fmtFull(e.at)} @@ -1195,6 +1215,11 @@ function eventDetail(e) { if (p.kind === 'project') return `项目「${p.name || ''}」已登记`; return `${p.title || name}(${CPLX_LABEL[p.complexity] || ''})`; } + if (e.type === 'merge.failed') { + const files = (p.conflictFiles && p.conflictFiles.length) ? `(冲突文件:${p.conflictFiles.join('、')})` : ''; + return `${name}:合并失败${files} → 补救任务`; + } + if (e.type === 'merge.remediated') return `${name}:补救完成 → 完成`; if (e.type === 'task.updated') return `${name} · 字段 ${p.field || ''}`; if (e.type === 'project.synced') { return `新建 ${p.created ?? 0} · 推进 done ${p.doneAdvanced ?? 0} · 跳过 ${p.skipped ?? 0}`; diff --git a/web/style.css b/web/style.css index e4d51a6..58f94a0 100644 --- a/web/style.css +++ b/web/style.css @@ -912,6 +912,14 @@ li.ev-updated { --ev: var(--muted); } .tl-who { color: var(--muted); font-size: 11px; } .archive-row { cursor: pointer; } +/* 合并失败横幅(结果评审闸) */ +.merge-failed-banner { + border-left: 3px solid var(--red, #d9534f); + background: rgba(217, 83, 79, 0.08); + padding: 8px 12px; border-radius: 4px; + font-size: 12px; line-height: 1.6; +} + /* 顶栏动作不换行(同步 todo / 配置 按钮文字曾被挤成两行) */ .top-actions { flex-wrap: nowrap; } .top-actions .btn, .sync-meta { white-space: nowrap; }