From fa4ba3db288f4ec6bb47a99bbbeacca3773d0fff Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Thu, 25 Jun 2026 20:15:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(rule13):=20=E6=89=A7=E8=A1=8C=E5=89=8D?= =?UTF-8?q?=E5=88=86=E6=AD=A7=E9=87=8D=E8=AF=84=E4=BC=B0=E9=97=B8=E2=80=94?= =?UTF-8?q?=E2=80=94main=20=E6=92=9E=E5=A3=B0=E6=98=8E=E8=8C=83=E5=9B=B4?= =?UTF-8?q?=E2=86=92needs=5Fattention=20=E9=87=8D=E8=AF=84=E4=BC=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通用规则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 --- docs/optimization-plan.html | 6 ++--- src/daemon/ingest.ts | 8 ++++++ src/executor/checks.ts | 39 +++++++++++++++++++++++++++ src/executor/pipeline.ts | 23 +++++++++++++++- src/executor/protocol.ts | 3 +++ src/model/status.ts | 2 +- src/store/store.ts | 19 +++++++++++++ test/checks.test.ts | 54 ++++++++++++++++++++++++++++++++++++- test/pipeline.test.ts | 27 +++++++++++++++++++ test/store.test.ts | 19 +++++++++++++ 10 files changed, 194 insertions(+), 6 deletions(-) diff --git a/docs/optimization-plan.html b/docs/optimization-plan.html index 4bb1134..20734b0 100644 --- a/docs/optimization-plan.html +++ b/docs/optimization-plan.html @@ -684,9 +684,9 @@
  • 13
    - 新增 - 执行前同步 main + 分歧重评估:任务正式执行前,先把 defaultBranch 最新代码 merge 进 worktree;若改动过大 / 耦合过深 / 存在逻辑冲突,则触发方案重评估(回 planner 重写 spec / 重拆,或转 needs_attention)而非盲目继续。 -
    与③协同:执行前同步冲突=早发现;执行后合并冲突=③兜底
    + ✅ 已实现 + 执行前同步 main + 分歧重评估:任务正式执行前,先把 defaultBranch 最新代码 merge 进 worktree;若改动过大 / 耦合过深 / 存在逻辑冲突,则触发方案重评估(转 needs_attention 待人工)而非盲目继续。 +
    实现:sync main 前先跑 reevalScopeGate——以 git diff HEAD...origin/main 取 main 自分支点以来改动的文件,与任务声明范围 scopeFiles 求交集;非空即 emit needs-reeval → daemon markReeval 直接转 needs_attention(不重试、不计失败次数、run 收尾 cancelled)。与③协同:执行前同步冲突=早发现;执行后合并冲突=③兜底
  • diff --git a/src/daemon/ingest.ts b/src/daemon/ingest.ts index 2dcc7ce..355a778 100644 --- a/src/daemon/ingest.ts +++ b/src/daemon/ingest.ts @@ -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); diff --git a/src/executor/checks.ts b/src/executor/checks.ts index 2fc3cc5..969116f 100644 --- a/src/executor/checks.ts +++ b/src/executor/checks.ts @@ -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; + +/** + * 执行前分歧重评估闸(规则13):在 sync main 之前,检测 defaultBranch 自任务分支点以来改动的文件, + * 是否与本任务声明范围(scopeFiles)重叠。重叠 = main 在任务排队期间动了本任务要改的文件, + * 原 plan/spec 可能已过时 → 触发重评估(daemon 据此把任务转 needs_attention 待人工)。 + * + * 用 `HEAD...`(三点)= 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 { + 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/ → 用本地 */ } + 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 }; +} diff --git a/src/executor/pipeline.ts b/src/executor/pipeline.ts index 305cf8f..21d0123 100644 --- a/src/executor/pipeline.ts +++ b/src/executor/pipeline.ts @@ -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/;失败再试本地 。 */ @@ -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) { diff --git a/src/executor/protocol.ts b/src/executor/protocol.ts index 4ce4619..0dfdd39 100644 --- a/src/executor/protocol.ts +++ b/src/executor/protocol.ts @@ -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, 'seq' | 'at'> | Omit, 'seq' | 'at'> | Omit, 'seq' | 'at'> + | Omit, 'seq' | 'at'> | Omit, 'seq' | 'at'> | Omit, 'seq' | 'at'> | Omit, 'seq' | 'at'> diff --git a/src/model/status.ts b/src/model/status.ts index fde0cbd..99629b0 100644 --- a/src/model/status.ts +++ b/src/model/status.ts @@ -65,7 +65,7 @@ export const TRANSITIONS: Record = { 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'], diff --git a/src/store/store.ts b/src/store/store.ts index 131b439..da67e8a 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -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(清退避)。 diff --git a/test/checks.test.ts b/test/checks.test.ts index 33a8b84..368f8d5 100644 --- a/test/checks.test.ts +++ b/test/checks.test.ts @@ -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): Promise | 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, []); +}); diff --git a/test/pipeline.test.ts b/test/pipeline.test.ts index 37e374f..46d59e5 100644 --- a/test/pipeline.test.ts +++ b/test/pipeline.test.ts @@ -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'); diff --git a/test/store.test.ts b/test/store.test.ts index 5ec07a5..b6b6ed4 100644 --- a/test/store.test.ts +++ b/test/store.test.ts @@ -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() });