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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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.scopeFileGate(task.scopeFiles 非空时才生效)。 */
|
||||
scopeGate?: ScopeGateFn;
|
||||
/** 执行前分歧重评估闸(规则13),默认 checks.reevalScopeGate(task.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 交给 outbox(worker 传 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) {
|
||||
|
||||
@@ -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
@@ -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'],
|
||||
|
||||
@@ -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
@@ -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('scopeFileGate:git 出错 → 不拦截', 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.md,main 只动了 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 无前移 → 空 overlap;git 出错 → 空', 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 tip,main 未前移
|
||||
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, []);
|
||||
});
|
||||
|
||||
@@ -264,6 +264,33 @@ test('planner:CC 失败 → 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');
|
||||
|
||||
@@ -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() });
|
||||
|
||||
Reference in New Issue
Block a user