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
+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(清退避)。