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:
+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