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:
+11
-1
@@ -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:<mergeCommit>
|
||||
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 {
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface MergeResult {
|
||||
ok: boolean;
|
||||
mergeCommit?: string; // 成功时为合并后 defaultBranch 的 HEAD(重复合并时为当前 HEAD,不新建提交)
|
||||
error?: string;
|
||||
conflictFiles?: string[]; // 失败为冲突时的冲突文件列表(结构化,供事件/时间线展示)
|
||||
}
|
||||
|
||||
/** 临时合并 worktree 目录(确定性):<dataDir>/worktrees/_merge/<taskId>/ */
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+45
-2
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user