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:
@@ -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(清退避)。
|
||||
|
||||
Reference in New Issue
Block a user