feat(agent): ② exec 自动放行(autoApproveExec)+ 抽出共享合并流程
- 新增 exec-merge.acceptAndMerge:封装 exec_review 通过并合并的完整流程 (merge→冲突建补救任务/成功 decide+收口+回收),API decide 与 ingest 复用同一逻辑 - server.ts decide 改调 acceptAndMerge(去重 ~40 行内联逻辑) - ingest result:auto-approved + autoApproveExec + 双复审 approve → fire-and-forget 自动合并(git 异步不阻塞 tick);冲突则补救任务 + 留 exec_review 人审 - 测试:acceptAndMerge 可合并→done、冲突→补救+留审核闸(真实 git repo) 至此 plan ① ② ③ 三类优化 + 通用规则 + L1-L4 记忆注入代码侧全部落地 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+9
-44
@@ -8,9 +8,8 @@ import type { Project, Autonomy } from '../model/types.js';
|
||||
import { syncProject, hasTodoJson } from '../sync/todo-sync.js';
|
||||
import { resolvedExecutorModels } from '../executor/models.js';
|
||||
import { classifyComplexity, type ClassifierFn } from '../executor/classify.js';
|
||||
import { mergeBranch } from '../executor/merge.js';
|
||||
import { git, removeWorktree, createWorktree } from '../executor/worktree.js';
|
||||
import { cleanupTaskRunArtifacts } from '../executor/cleanup.js';
|
||||
import { acceptAndMerge } from '../executor/exec-merge.js';
|
||||
import { createWorktree } from '../executor/worktree.js';
|
||||
import { resolveLogo, LOGO_MIME } from './logo.js';
|
||||
import { readTranscript, TranscriptError } from '../executor/transcript.js';
|
||||
import { createReadStream, createWriteStream, mkdirSync, readFileSync, statSync } from 'node:fs';
|
||||
@@ -428,48 +427,14 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
|
||||
const task = store.getTask(id);
|
||||
// merge:false = 仅通过不合并(逃生口:如目标分支正被用户工作区检出导致自动合并不可用)
|
||||
if (task && task.status === 'exec_review' && b.action === 'accept' && task.result?.branch && b.merge !== false) {
|
||||
const project = store.getProject(task.projectId);
|
||||
if (!project) throw new StoreError(`项目不存在: ${task.projectId}`);
|
||||
const { branch, worktree } = task.result;
|
||||
const mr = await mergeBranch(project.repoPath, branch, project.defaultBranch, task.id);
|
||||
if (!mr.ok) {
|
||||
// 自动合并失败(多为冲突)→ 建/复用最高优先级补救任务来完成合并,原任务留在审核闸
|
||||
const rem = store.ensureMergeRemediationTask(task.id, {
|
||||
branch, targetBranch: project.defaultBranch,
|
||||
conflictError: mr.error ?? '未知冲突', conflictFiles: mr.conflictFiles,
|
||||
});
|
||||
throw new StoreError(`合并失败:${mr.error}(原任务保留在审核闸;已建最高优先级补救任务 ${rem.id}「${rem.title}」来完成合并)`);
|
||||
const outcome = await acceptAndMerge(store, app.log, task, b.actor ?? 'user');
|
||||
if (!outcome.ok) {
|
||||
if (outcome.remediationTaskId) {
|
||||
const rem = store.getTask(outcome.remediationTaskId);
|
||||
throw new StoreError(`合并失败:${outcome.error}(原任务保留在审核闸;已建最高优先级补救任务 ${rem?.id}「${rem?.title}」来完成合并)`);
|
||||
}
|
||||
throw new StoreError(`合并失败:${outcome.error}`);
|
||||
}
|
||||
|
||||
store.decide(id, 'accept', b.actor ?? 'user', b.reason ?? null);
|
||||
// 合并产物记录:复用 prUrl 字段写 merged:<mergeCommit>
|
||||
store.setResult(id, { ...task.result, prUrl: `merged:${mr.mergeCommit}` });
|
||||
// 即时回收 runs/<runId>/(job/outbox/heartbeat 已无价值;转录保留供排查)。
|
||||
// 任务此刻已 done(终态),cleanupTaskRunArtifacts 内部据此放行;同步删(纯本地 fs,快)。
|
||||
const reclaimed = cleanupTaskRunArtifacts(store, id, app.log);
|
||||
if (reclaimed) app.log.info(`任务 ${id} 合并后回收 ${reclaimed} 个 run 工作目录`);
|
||||
// 补救完成自动收口:若本任务是某原任务的「合并冲突补救」任务,合并成功后把原任务也标 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 {
|
||||
if (worktree) await removeWorktree(project.repoPath, worktree);
|
||||
} catch (e) {
|
||||
app.log.error(`任务 ${id} 合并后清理 worktree 失败(不影响结果):${(e as Error).message}`);
|
||||
}
|
||||
try {
|
||||
await git(project.repoPath, ['branch', '-d', branch]);
|
||||
} catch (e) {
|
||||
app.log.error(`任务 ${id} 合并后删除分支 ${branch} 失败(不影响结果):${(e as Error).message}`);
|
||||
}
|
||||
})();
|
||||
return store.getTask(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Store } from '../store/index.js';
|
||||
import { readOutboxSince, type OutboxRecord } from '../executor/protocol.js';
|
||||
import { acceptAndMerge } from '../executor/exec-merge.js';
|
||||
|
||||
/**
|
||||
* Outbox → DB 摄取(daemon 是唯一 DB 写者)。
|
||||
@@ -40,6 +41,12 @@ function shouldAutoApprovePlan(
|
||||
return subtasks.every((s) => s.complexity === 'easy');
|
||||
}
|
||||
|
||||
/** exec 自动放行开关:项目 auto-approved 且 autoApproveExec 打开(默认关)。 */
|
||||
function autoApproveExecOn(store: Store, projectId: string): boolean {
|
||||
const project = store.getProject(projectId);
|
||||
return !!project && project.autonomy === 'auto-approved' && !!project.autoApproveExec;
|
||||
}
|
||||
|
||||
/**
|
||||
* 摄取单个 run 的新 outbox 记录(seq>run.lastSeq),按序落库。
|
||||
* run 不存在则忽略(已被删/异常)。每条处理后推进 lastSeq 游标。
|
||||
@@ -202,6 +209,20 @@ function applyRecord(store: Store, log: IngestLogger, taskId: string, runId: str
|
||||
claudeSessionId: rec.executor.sessionId ?? undefined,
|
||||
});
|
||||
log.info(`task=${taskId} run=${runId} 执行完成 → exec_review(${rec.commits.length} commits)`);
|
||||
|
||||
// 自动放行(默认关):auto-approved + autoApproveExec + 双复审均 approve → 自动合并。
|
||||
// 合并是 git 异步操作,fire-and-forget 不阻塞 tick;冲突则补救任务 + 留 exec_review 人审。
|
||||
if (rec.code.verdict === 'approve' && rec.security.verdict === 'approve' && autoApproveExecOn(store, taskId)) {
|
||||
const reviewed = store.getTask(taskId);
|
||||
if (reviewed) {
|
||||
void acceptAndMerge(store, log, reviewed, 'auto-approve-exec')
|
||||
.then((o) => {
|
||||
if (o.ok) log.info(`task=${taskId} 双复审 approve + 自动放行 → 已自动合并(${o.mergeCommit})`);
|
||||
else log.info(`task=${taskId} 自动合并未成(${o.error})→ 留 exec_review 人审${o.remediationTaskId ? `(补救任务 ${o.remediationTaskId})` : ''}`);
|
||||
})
|
||||
.catch((e) => log.error(`task=${taskId} 自动合并异常:${(e as Error).message}`));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Store } from '../store/index.js';
|
||||
import type { Task } from '../model/types.js';
|
||||
import { mergeBranch } from './merge.js';
|
||||
import { git, removeWorktree } from './worktree.js';
|
||||
import { cleanupTaskRunArtifacts } from './cleanup.js';
|
||||
|
||||
export interface ExecMergeLogger {
|
||||
info(msg: string): void;
|
||||
error(msg: string): void;
|
||||
}
|
||||
|
||||
export interface ExecMergeOutcome {
|
||||
ok: boolean;
|
||||
mergeCommit?: string;
|
||||
remediationTaskId?: string; // 冲突时建的补救任务 id
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* exec_review「通过并合并」(PR 闭环的 merge 收尾)。前置:task 已在 exec_review、result.branch 存在。
|
||||
* - 成功:decide(accept)→done + result.prUrl=merged:<commit> + 回收 run 工件 + 补救来源收口
|
||||
* + 异步删 worktree/已并分支;返回 { ok:true, mergeCommit }。
|
||||
* - 冲突:建/复用最高优先级补救任务,**原任务留 exec_review(不 decide)**;返回 { ok:false, remediationTaskId }。
|
||||
* 同时供 API 手动 accept 与 ingest 的 autoApproveExec 自动放行复用,保证两条路径行为一致。
|
||||
*/
|
||||
export async function acceptAndMerge(
|
||||
store: Store,
|
||||
log: ExecMergeLogger,
|
||||
task: Task,
|
||||
actor: string,
|
||||
): Promise<ExecMergeOutcome> {
|
||||
const project = store.getProject(task.projectId);
|
||||
if (!project) return { ok: false, error: `项目不存在: ${task.projectId}` };
|
||||
const branch = task.result?.branch ?? null;
|
||||
const worktree = task.result?.worktree ?? null;
|
||||
if (!branch) return { ok: false, error: '任务无分支结果,无法合并' };
|
||||
|
||||
const mr = await mergeBranch(project.repoPath, branch, project.defaultBranch, task.id);
|
||||
if (!mr.ok) {
|
||||
// 自动合并失败(多为冲突)→ 建/复用最高优先级补救任务,原任务保留在审核闸
|
||||
const rem = store.ensureMergeRemediationTask(task.id, {
|
||||
branch, targetBranch: project.defaultBranch,
|
||||
conflictError: mr.error ?? '未知冲突', conflictFiles: mr.conflictFiles,
|
||||
});
|
||||
return { ok: false, remediationTaskId: rem.id, error: mr.error };
|
||||
}
|
||||
|
||||
store.decide(task.id, 'accept', actor, null);
|
||||
store.setResult(task.id, { ...task.result!, prUrl: `merged:${mr.mergeCommit}` });
|
||||
|
||||
// 即时回收 runs/<runId>/(任务已 done,cleanup 内部据此放行)
|
||||
const reclaimed = cleanupTaskRunArtifacts(store, task.id, log);
|
||||
if (reclaimed) log.info(`任务 ${task.id} 合并后回收 ${reclaimed} 个 run 工作目录`);
|
||||
|
||||
// 补救完成自动收口:若本任务是某原任务的「合并冲突补救」,合并成功后把原任务也标 done
|
||||
const origin = store.findMergeOriginTask(task.id);
|
||||
if (origin) {
|
||||
try {
|
||||
store.remediateOrigin(origin.id, task.id, mr.mergeCommit!);
|
||||
} catch (e) {
|
||||
log.error(`补救任务 ${task.id} 合并后收口原任务 ${origin.id} 失败(不影响本任务结果):${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 异步回收:执行 worktree + 已合并的任务分支(失败只记日志)
|
||||
void (async () => {
|
||||
try {
|
||||
if (worktree) await removeWorktree(project.repoPath, worktree);
|
||||
} catch (e) {
|
||||
log.error(`任务 ${task.id} 合并后清理 worktree 失败(不影响结果):${(e as Error).message}`);
|
||||
}
|
||||
try {
|
||||
await git(project.repoPath, ['branch', '-d', branch]);
|
||||
} catch (e) {
|
||||
log.error(`任务 ${task.id} 合并后删除分支 ${branch} 失败(不影响结果):${(e as Error).message}`);
|
||||
}
|
||||
})();
|
||||
|
||||
return { ok: true, mergeCommit: mr.mergeCommit };
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../src/store/index.js';
|
||||
import { acceptAndMerge } from '../src/executor/exec-merge.js';
|
||||
import type { Task } from '../src/model/types.js';
|
||||
|
||||
const noopLog = { info() {}, error() {} };
|
||||
|
||||
function git(cwd: string, args: string[]): string {
|
||||
return execFileSync('git', args, { cwd, encoding: 'utf8' });
|
||||
}
|
||||
|
||||
/** 独立 dataDir + 真实 git repo(main 含 1 commit),t.after 收尾。 */
|
||||
function setup(t: { after: (fn: () => void) => void }): string {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'maestro-em-data-'));
|
||||
const prev = process.env.MAESTRO_DATA_DIR;
|
||||
process.env.MAESTRO_DATA_DIR = dataDir;
|
||||
const repo = mkdtempSync(join(tmpdir(), 'maestro-em-repo-'));
|
||||
git(repo, ['init', '-b', 'main']);
|
||||
git(repo, ['config', 'user.name', 'em']); git(repo, ['config', 'user.email', 'em@t']);
|
||||
writeFileSync(join(repo, 'README.md'), '# demo\n');
|
||||
git(repo, ['add', '-A']); git(repo, ['commit', '-qm', 'init']);
|
||||
t.after(() => {
|
||||
if (prev === undefined) delete process.env.MAESTRO_DATA_DIR; else process.env.MAESTRO_DATA_DIR = prev;
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
});
|
||||
return repo;
|
||||
}
|
||||
|
||||
/** 在 store 里把任务推到 exec_review 且带 result(双 verdict=approve)。 */
|
||||
function taskAtExecReview(s: Store, projectId: string, branch: string): Task {
|
||||
const t = s.createTask({ projectId, title: 'feat', complexity: 'easy' });
|
||||
s.setOperations(t.id, 'do');
|
||||
s.transition(t.id, 'queued');
|
||||
s.transition(t.id, 'executing');
|
||||
s.setResult(t.id, {
|
||||
branch, worktree: null, diffSummary: '', commits: [], prUrl: null,
|
||||
summary: 'ok', verdict: 'approve', securitySummary: 'ok', securityVerdict: 'approve', mergeTaskId: null,
|
||||
});
|
||||
s.transition(t.id, 'exec_review');
|
||||
return s.getTask(t.id)!;
|
||||
}
|
||||
|
||||
test('acceptAndMerge:可合并 → 任务 done + result.prUrl=merged:<commit>', async (t) => {
|
||||
const repo = setup(t);
|
||||
const s = new Store(':memory:');
|
||||
const p = s.createProject({ name: 'em', repoPath: repo });
|
||||
const branch = 'maestro/tsk_ok';
|
||||
git(repo, ['checkout', '-q', '-b', branch]);
|
||||
writeFileSync(join(repo, 'feat.txt'), 'F\n');
|
||||
git(repo, ['add', '-A']); git(repo, ['commit', '-qm', 'maestro(tsk_ok): feat']);
|
||||
git(repo, ['switch', '-q', '--detach']); // 释放 main,让合并走临时 worktree
|
||||
|
||||
const task = taskAtExecReview(s, p.id, branch);
|
||||
const outcome = await acceptAndMerge(s, noopLog, task, 'test');
|
||||
assert.equal(outcome.ok, true, outcome.error);
|
||||
assert.ok(outcome.mergeCommit);
|
||||
const after = s.getTask(task.id)!;
|
||||
assert.equal(after.status, 'done', '合并成功 → done');
|
||||
assert.match(after.result!.prUrl!, /^merged:/);
|
||||
s.close();
|
||||
});
|
||||
|
||||
test('acceptAndMerge:冲突 → ok:false + 建补救任务 + 原任务留 exec_review', async (t) => {
|
||||
const repo = setup(t);
|
||||
const s = new Store(':memory:');
|
||||
const p = s.createProject({ name: 'em', repoPath: repo });
|
||||
// main 与 branch 改同一文件同一行 → 冲突
|
||||
writeFileSync(join(repo, 'shared.txt'), 'base\n');
|
||||
git(repo, ['add', '-A']); git(repo, ['commit', '-qm', 'base shared']);
|
||||
const branch = 'maestro/tsk_conf';
|
||||
git(repo, ['checkout', '-q', '-b', branch]);
|
||||
writeFileSync(join(repo, 'shared.txt'), 'BRANCH\n');
|
||||
git(repo, ['add', '-A']); git(repo, ['commit', '-qm', 'maestro(tsk_conf): branch side']);
|
||||
git(repo, ['checkout', '-q', 'main']);
|
||||
writeFileSync(join(repo, 'shared.txt'), 'MAIN\n');
|
||||
git(repo, ['add', '-A']); git(repo, ['commit', '-qm', 'main side']);
|
||||
git(repo, ['switch', '-q', '--detach']);
|
||||
|
||||
const task = taskAtExecReview(s, p.id, branch);
|
||||
const outcome = await acceptAndMerge(s, noopLog, task, 'test');
|
||||
assert.equal(outcome.ok, false, '冲突应失败');
|
||||
assert.ok(outcome.remediationTaskId, '应建补救任务');
|
||||
assert.equal(s.getTask(task.id)!.status, 'exec_review', '原任务留审核闸(不 decide)');
|
||||
s.close();
|
||||
});
|
||||
Reference in New Issue
Block a user