feat(rule13): 执行前分歧重评估闸——main 撞声明范围→needs_attention 重评估

通用规则13的(B)半边:sync main 之前先跑 reevalScopeGate,
以 git diff HEAD...origin/main 取 main 自分支点以来改动的文件,
与任务 scopeFiles 求交集;非空即 emit needs-reeval →
daemon markReeval 直接转 needs_attention(不重试/不计失败/run收尾cancelled)。

- checks.ts: + reevalScopeGate / ReevalGateFn / ReevalGateResult
- pipeline.ts: createWorktree 后、syncMain 前插入重评估闸 + reevalGate 依赖注入
- protocol.ts: + needs-reeval OutboxRecord/Payload 事件
- ingest.ts: + case 'needs-reeval' → store.markReeval
- store.ts: + markReeval(executing→needs_attention,不进重试链)
- status.ts: executing 合法转移 + needs_attention
- 测试 +5:reevalScopeGate(命中/未命中/空/无前移/git错) · pipeline(命中emit/未命中续跑) · store.markReeval;全套 242 绿
- docs: optimization-plan.html 规则13 标  已实现

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-25 20:15:02 +08:00
parent 6bf2b3619c
commit fa4ba3db28
10 changed files with 194 additions and 6 deletions
+3 -3
View File
@@ -684,9 +684,9 @@
<li class="rule-item">
<div class="rule-num">13</div>
<div class="rule-body">
<span class="tag tag-new">新增</span>
<strong>执行前同步 main + 分歧重评估</strong>:任务正式执行前,先把 defaultBranch 最新代码 merge 进 worktree;若改动过大 / 耦合过深 / 存在逻辑冲突,则触发方案重评估(回 planner 重写 spec / 重拆,或转 needs_attention)而非盲目继续。
<div class="rule-impl">与③协同:执行前同步冲突=早发现;执行后合并冲突=③兜底</div>
<span class="tag tag-approved">✅ 已实现</span>
<strong>执行前同步 main + 分歧重评估</strong>:任务正式执行前,先把 defaultBranch 最新代码 merge 进 worktree;若改动过大 / 耦合过深 / 存在逻辑冲突,则触发方案重评估(转 needs_attention 待人工)而非盲目继续。
<div class="rule-impl">实现:sync main 前先跑 <code>reevalScopeGate</code>——以 <code>git diff HEAD...origin/main</code> 取 main 自分支点以来改动的文件,与任务声明范围 <code>scopeFiles</code> 求交集;非空即 emit <code>needs-reeval</code> → daemon <code>markReeval</code> 直接转 <code>needs_attention</code>(不重试、不计失败次数、run 收尾 cancelled)。与③协同:执行前同步冲突=早发现;执行后合并冲突=③兜底</div>
</div>
</li>
</ul>
+8
View File
@@ -108,6 +108,14 @@ function applyRecord(store: Store, log: IngestLogger, taskId: string, runId: str
return;
}
case 'needs-reeval': {
// 规则13 执行前分歧重评估:main 改动撞上任务声明范围,原方案可能过时 →
// 不重试、不计失败次数,直接转 needs_attention 待人工(run 收尾为 cancelled)。
store.markReeval(taskId, runId, rec.reason);
log.info(`task=${taskId} run=${runId} 执行前分歧重评估 → needs_attention(撞 ${rec.files.length} 个声明范围内文件):${rec.reason}`);
return;
}
case 'spec-result': {
// planner-spec 成功:写方案 → 转 spec_review(待你审)→ 收尾 planner run。
store.setSpec(taskId, rec.spec);
+39
View File
@@ -201,3 +201,42 @@ export async function scopeFileGate(repoPath: string, branch: string, baseBranch
}
return { ok: true, outside };
}
// ───────────────────────── 执行前分歧重评估闸(通用规则 13)─────────────────────────
export interface ReevalGateResult {
overlap: string[]; // defaultBranch 新改动里、落在 task.scopeFiles 声明范围内的文件
}
/** 分歧重评估闸函数签名(pipeline 依赖注入点,测试可 mock)。 */
export type ReevalGateFn = (dir: string, defaultBranch: string, scopeFiles: string[]) => Promise<ReevalGateResult>;
/**
* 执行前分歧重评估闸(规则13):在 sync main 之前,检测 defaultBranch 自任务分支点以来改动的文件,
* 是否与本任务声明范围(scopeFiles)重叠。重叠 = main 在任务排队期间动了本任务要改的文件,
* 原 plan/spec 可能已过时 → 触发重评估(daemon 据此把任务转 needs_attention 待人工)。
*
* 用 `HEAD...<ref>`(三点)= merge-base(HEAD, ref)→ref 的 diff = defaultBranch 自分支点以来的新改动。
* 此刻 worktree 分支尚无任务提交(sync 在 runTask 之前),故 merge-base 即建分支点。
* scopeFiles 为空 → 不评估(空重叠)。git 出错 / 无新改动 → 不拦截(空重叠)。
*/
export async function reevalScopeGate(dir: string, defaultBranch: string, scopeFiles: string[]): Promise<ReevalGateResult> {
const patterns = scopeFiles.map((s) => s.trim()).filter(Boolean);
if (patterns.length === 0) return { overlap: [] };
try { await git(dir, ['fetch', 'origin', defaultBranch]); } catch { /* 无远端时跳过 fetch,用本地 ref */ }
let ref = defaultBranch;
try {
await git(dir, ['rev-parse', '--verify', '--quiet', `origin/${defaultBranch}`]);
ref = `origin/${defaultBranch}`;
} catch { /* 无 origin/<branch> → 用本地 <branch> */ }
let out: string;
try {
out = (await git(dir, ['diff', '--name-only', `HEAD...${ref}`])).trim();
} catch {
return { overlap: [] }; // 测不出 main 新改动(git 出错)→ 不拦截
}
if (!out) return { overlap: [] };
const mainChanged = out.split('\n').map((s) => s.trim()).filter(Boolean);
const overlap = mainChanged.filter((f) => matchesAnyGlob(f, patterns));
return { overlap };
}
+22 -1
View File
@@ -19,7 +19,7 @@ import { createWorktree, worktreeDiff, git, type WorktreeDiff, type WorktreeInfo
import { runTask, runPlanner, runConflict, type RunnerFn, type PlannerFn } from './runner.js';
import { runVerify, type VerifyFn } from './verify.js';
import { reviewCode, reviewSecurity, type ReviewerFn } from './reviewer.js';
import { runChecks, diffSizeGate, scopeFileGate, type ChecksFn, type DiffGateFn, type ScopeGateFn } from './checks.js';
import { runChecks, diffSizeGate, scopeFileGate, reevalScopeGate, type ChecksFn, type DiffGateFn, type ScopeGateFn, type ReevalGateFn } from './checks.js';
/** pipeline 的依赖注入面(便于单测 mock);默认值取真实现。 */
export interface PipelineDeps {
@@ -39,6 +39,8 @@ export interface PipelineDeps {
diffGate?: DiffGateFn;
/** diff 声明外文件闸,默认 checks.scopeFileGatetask.scopeFiles 非空时才生效)。 */
scopeGate?: ScopeGateFn;
/** 执行前分歧重评估闸(规则13),默认 checks.reevalScopeGatetask.scopeFiles 非空时才生效)。 */
reevalGate?: ReevalGateFn;
}
/** 执行前同步 defaultBranch 默认实现:fetch→merge origin/<branch>;失败再试本地 <branch>。 */
@@ -90,6 +92,7 @@ export const realDeps: PipelineDeps = {
checks: runChecks,
diffGate: diffSizeGate,
scopeGate: scopeFileGate,
reevalGate: reevalScopeGate,
};
/** emit:把一条 OutboxPayload 交给 outboxworker 传 appendOutbox 包装,测试传收集器)。 */
@@ -148,6 +151,24 @@ export async function runPipeline(job: JobSpec, deps: PipelineDeps = realDeps, e
// 1. 建 worktree(其 dir/branch 应与 job.worktreeDir/branch 一致;以 deps 真建的为准)
const wt = await deps.createWorktree(job.project.repoPath, job.task.id, job.project.defaultBranch);
// 规则13 执行前分歧重评估:sync main 之前,先看 defaultBranch 在排队期间是否动了本任务声明范围内的文件。
// 撞上 = 原方案可能已过时 → 不盲目继续,转 needs_attention 待人工重评估(不重试)。scopeFiles 空则跳过。
const reevalScope = job.task.scopeFiles;
if (reevalScope && reevalScope.length > 0) {
const rg = await (deps.reevalGate ?? reevalScopeGate)(wt.dir, job.project.defaultBranch, reevalScope);
if (rg.overlap.length > 0) {
const shown = rg.overlap.slice(0, 10).join('、');
const more = rg.overlap.length > 10 ? `${rg.overlap.length}` : '';
emit({
type: 'needs-reeval',
reason: `执行前同步发现 ${job.project.defaultBranch} 已改动本任务声明范围内的文件(${shown}${more}),原方案可能已过时,转人工重新评估`,
files: rg.overlap,
});
emit({ type: 'done' });
return;
}
}
// sync main:把 defaultBranch 最新代码 merge 进 worktree(排队期间 main 可能已前移)
const syncErr = await (deps.syncMain ?? defaultSyncMain)(wt.dir, job.project.defaultBranch);
if (syncErr !== null) {
+3
View File
@@ -124,6 +124,8 @@ export type OutboxRecord =
| { seq: number; at: string; type: 'started'; pid: number; worktree: string; branch: string; model: string }
| { seq: number; at: string; type: 'phase'; phase: string }
| { seq: number; at: string; type: 'failed'; error: string; transcriptRef: string | null; sessionId: string | null }
// 执行前分歧重评估(规则13):main 改动撞上任务声明范围 → daemon 转 needs_attention(不重试)
| { seq: number; at: string; type: 'needs-reeval'; reason: string; files: string[] }
| {
seq: number; at: string; type: 'result';
branch: string; worktree: string; diffSummary: string; commits: string[];
@@ -152,6 +154,7 @@ export type OutboxPayload =
| Omit<Extract<OutboxRecord, { type: 'started' }>, 'seq' | 'at'>
| Omit<Extract<OutboxRecord, { type: 'phase' }>, 'seq' | 'at'>
| Omit<Extract<OutboxRecord, { type: 'failed' }>, 'seq' | 'at'>
| Omit<Extract<OutboxRecord, { type: 'needs-reeval' }>, 'seq' | 'at'>
| Omit<Extract<OutboxRecord, { type: 'result' }>, 'seq' | 'at'>
| Omit<Extract<OutboxRecord, { type: 'spec-result' }>, 'seq' | 'at'>
| Omit<Extract<OutboxRecord, { type: 'decompose-result' }>, 'seq' | 'at'>
+1 -1
View File
@@ -65,7 +65,7 @@ export const TRANSITIONS: Record<TaskStatus, TaskStatus[]> = {
ready: ['blocked', 'queued', 'speccing', 'analyzing', 'cancelled', 'paused'],
blocked: ['ready', 'cancelled', 'paused'],
queued: ['executing', 'ready', 'cancelled', 'paused'],
executing: ['exec_review', 'failed', 'cancelled'],
executing: ['exec_review', 'failed', 'needs_attention', 'cancelled'], // 完成/失败/执行前分歧重评估(规则13)
exec_review: ['done', 'ready', 'failed', 'cancelled'], // accept(合并) / reject(返工)
failed: ['queued', 'needs_attention', 'cancelled'], // 重试 / 升级
needs_attention: ['ready', 'queued', 'analyzing', 'speccing', 'cancelled', 'paused'],
+19
View File
@@ -755,6 +755,25 @@ export class Store {
return this.getTask(taskId)!;
}
/**
* 通用规则13 执行前分歧重评估:sync main 发现 defaultBranch 改动与任务声明范围(scopeFiles)重叠,
* 原方案可能过时 → 不重试、不计失败次数,直接把任务转 needs_attention 待人工重评估。
* 与 failTaskAttempt 区别:run 收尾为 cancelled(未真失败,是主动停下重评估),不退避、不进重试链。
*/
markReeval(taskId: string, runId: string | null, reason: string): Task {
const row = this.getTaskRow(taskId);
if (!row) throw new StoreError(`任务不存在: ${taskId}`);
if (runId) {
const rr = this.db.prepare(`SELECT status FROM runs WHERE id = ?`).get(runId) as { status: string } | undefined;
if (rr && rr.status === 'started') this.finishRun(runId, 'cancelled', { error: reason });
}
this.setNextEligibleAt(taskId, null);
if ((this.getTaskRow(taskId)!.status as TaskStatus) !== 'needs_attention') {
this.transition(taskId, 'needs_attention', { by: 'markReeval', reason });
}
return this.getTask(taskId)!;
}
/**
* planner(拆解 Hard / 写方案 Medium)失败的落点:收尾 planner run + 退避,任务【留在 analyzing/speccing】
* 等退避到期重新被领取;累计 planner 失败超 project.maxRetries → 升级 needs_attention(清退避)。
+53 -1
View File
@@ -4,7 +4,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import { parseChecks, runChecks, diffSizeGate, globToRegExp, matchesAnyGlob, scopeFileGate } from '../src/executor/checks.js';
import { parseChecks, runChecks, diffSizeGate, globToRegExp, matchesAnyGlob, scopeFileGate, reevalScopeGate } from '../src/executor/checks.js';
import type { Project } from '../src/model/types.js';
function withTmpData(cb: (dir: string) => Promise<void> | void): Promise<void> | void {
@@ -145,3 +145,55 @@ test('scopeFileGategit 出错 → 不拦截', async () => {
const r = await scopeFileGate('/nonexistent-repo-xyz', 'a', 'b', ['src/**']);
assert.equal(r.ok, true);
});
test('reevalScopeGate(规则13):main 动了声明范围内文件 → overlap 非空;范围外 → 空;空声明 → 空', async () => {
const repo = mkdtempSync(join(tmpdir(), 'maestro-reeval-'));
const g = (args: string[]) => execFileSync('git', args, { cwd: repo }).toString();
g(['init', '-q', '-b', 'main']);
g(['config', 'user.email', 't@t']); g(['config', 'user.name', 't']);
execFileSync('mkdir', ['-p', join(repo, 'src')]);
writeFileSync(join(repo, 'src', 'a.ts'), 'a\n');
writeFileSync(join(repo, 'docs.md'), 'd\n');
g(['add', '-A']); g(['commit', '-qm', 'base']);
// 任务分支从此刻 main 切出(尚无任务提交,模拟 sync 前的 worktree HEAD
g(['checkout', '-q', '-b', 'feature']);
// main 在任务排队期间前移:改了 src/a.ts(声明范围内)
g(['checkout', '-q', 'main']);
writeFileSync(join(repo, 'src', 'a.ts'), 'a changed by main\n');
g(['add', '-A']); g(['commit', '-qm', 'main moved']);
g(['checkout', '-q', 'feature']); // worktree HEAD 回到分支点
try {
// 声明 src/**main 动了 src/a.ts → 撞上,触发重评估
const hit = await reevalScopeGate(repo, 'main', ['src/**']);
assert.deepEqual(hit.overlap, ['src/a.ts']);
// 声明 docs.mdmain 只动了 src/a.ts → 不撞,不触发
const miss = await reevalScopeGate(repo, 'main', ['docs.md']);
assert.deepEqual(miss.overlap, []);
// 空声明 → 不评估
const skip = await reevalScopeGate(repo, 'main', []);
assert.deepEqual(skip.overlap, []);
} finally {
rmSync(repo, { recursive: true, force: true });
}
});
test('reevalScopeGate(规则13):main 无前移 → 空 overlapgit 出错 → 空', async () => {
const repo = mkdtempSync(join(tmpdir(), 'maestro-reeval2-'));
const g = (args: string[]) => execFileSync('git', args, { cwd: repo }).toString();
g(['init', '-q', '-b', 'main']);
g(['config', 'user.email', 't@t']); g(['config', 'user.name', 't']);
writeFileSync(join(repo, 'src.ts'), 'x\n');
g(['add', '-A']); g(['commit', '-qm', 'base']);
g(['checkout', '-q', '-b', 'feature']); // 分支点 = main tipmain 未前移
try {
const r = await reevalScopeGate(repo, 'main', ['**']);
assert.deepEqual(r.overlap, []);
} finally {
rmSync(repo, { recursive: true, force: true });
}
const err = await reevalScopeGate('/nonexistent-repo-xyz', 'main', ['src/**']);
assert.deepEqual(err.overlap, []);
});
+27
View File
@@ -264,6 +264,33 @@ test('plannerCC 失败 → emit failed', async () => {
assert.equal(emitted.at(-1)?.type, 'done');
});
test('规则13 分歧重评估:reevalGate 撞声明范围 → emit needs-reeval + done,不执行/不复审/无 failed', async () => {
const { emitted, emit } = collector();
const job: JobSpec = { ...fakeJob, task: { ...fakeTask, scopeFiles: ['src/**'] } };
let ranTask = false;
await runPipeline(job, mockDeps({
reevalGate: async () => ({ overlap: ['src/a.ts', 'src/b.ts'] }),
runTask: async () => { ranTask = true; return okRun; },
}), emit);
const re = find(emitted, 'needs-reeval');
assert.ok(re, '撞声明范围应 emit needs-reeval');
assert.deepEqual(re.files, ['src/a.ts', 'src/b.ts']);
assert.match(re.reason, /原方案可能已过时/);
assert.equal(ranTask, false, '重评估时不应执行任务');
assert.equal(find(emitted, 'result'), undefined, '不应 emit result');
assert.equal(find(emitted, 'failed'), undefined, '重评估不是失败,不应 emit failed');
assert.equal(emitted.at(-1)?.type, 'done', '末尾应为 done');
});
test('规则13 分歧重评估:无重叠 → 正常往下执行(emit result', async () => {
const { emitted, emit } = collector();
const job: JobSpec = { ...fakeJob, task: { ...fakeTask, scopeFiles: ['src/**'] } };
await runPipeline(job, mockDeps({ reevalGate: async () => ({ overlap: [] }) }), emit);
assert.equal(find(emitted, 'needs-reeval'), undefined, '无重叠不应触发重评估');
assert.ok(find(emitted, 'result'), '应正常执行到 result');
});
test('parseDecompose:取最后一个 json 块;非法/缺失 → null', () => {
assert.equal(parseDecompose('no json here'), null);
assert.equal(parseDecompose('```json\n{"subtasks":[]}\n```'), null, '空 subtasks → null');
+19
View File
@@ -144,6 +144,25 @@ test('exec_review 结果闸:accept → done', () => {
s.close();
});
test('markReeval(规则13):executing → needs_attention,收尾 run 为 cancelled,不计执行失败次数', () => {
const s = freshStore();
const p = s.createProject({ name: 'rev', repoPath: '/tmp/rev-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy', scopeFiles: ['src/**'] });
s.setOperations(t.id, 'op');
s.transition(t.id, 'queued');
s.transition(t.id, 'executing');
const run = s.startRun(t.id, 'executor');
const after = s.markReeval(t.id, run.id, '执行前同步发现 main 已改动声明范围内文件');
assert.equal(after.status, 'needs_attention', '应直接转 needs_attention');
assert.equal(s.getRun(run.id)!.status, 'cancelled', 'run 收尾为 cancelled(非 failed');
// 没有任何 executor run 被标 failed(重评估不计失败次数 / 不进重试链)
const failedRuns = s.listRuns(t.id).filter((r) => r.kind === 'executor' && r.status === 'failed');
assert.equal(failedRuns.length, 0, 'markReeval 不应产生 failed 的 executor run');
s.close();
});
test('reconcileInterrupted:执行中被 cancel 的任务仍挂 started run → 仅收尾不崩(绝不非法 cancelled→failed', () => {
const s = freshStore();
const p = s.createProject({ name: 'recon', repoPath: '/tmp/recon-' + Math.random() });