feat(models+gate): 模型按项目可配置(默认 opus-4.8) + diff 声明外文件硬闸
① 模型可配置(项目维度,默认 opus-4.8)
- 新增 projects.models(JSON,按角色 executor/planner/reviewer/conflict
覆盖,值可为字符串=全复杂度统一 或 {easy,medium,hard} 分档)
- models.ts 重构 resolveModel:优先级 项目级 models > 旧 project.model
(仅 executor/planner) > env > 默认 DEFAULT_MODEL(opus-4.8)
- 取消内置 fable/sonnet 分档默认:所有角色默认 opus-4.8(彻底回避 fable-5
不可用问题,需要时项目级显式配置即可);回退链改 opus→sonnet→fable
- API PATCH /projects 透传 models;sanitizeModels 落库校验
② diff 声明外文件闸(task.scopeFiles)
- 新增 tasks.scope_files(JSON glob/路径数组)
- checks.ts: globToRegExp/matchesAnyGlob + scopeFileGate(改动文件越界=硬闸,
空声明跳过,git 出错不拦截);pipeline runApproveGates 接入
- planner 拆解新增每子任务 files 字段:prompt 要求 + parseDecompose 解析 +
ingest 落 scopeFiles,自动填充声明范围
- executor prompt 注入「声明文件范围约束」,让 agent 知边界(gate 才公平)
迁移:projects.models / tasks.scope_files 走 ensureColumn 幂等迁移(旧库补列)
测试:models 默认/配置/优先级、scope glob/gate、planner files 解析、
store 持久化往返、迁移补列 —— 237 通过
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+52
-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 } from '../src/executor/checks.js';
|
||||
import { parseChecks, runChecks, diffSizeGate, globToRegExp, matchesAnyGlob, scopeFileGate } from '../src/executor/checks.js';
|
||||
import type { Project } from '../src/model/types.js';
|
||||
|
||||
function withTmpData(cb: (dir: string) => Promise<void> | void): Promise<void> | void {
|
||||
@@ -94,3 +94,54 @@ test('diffSizeGate:git 出错 → 不拦截(测不出体量)', async () =>
|
||||
const r = await diffSizeGate('/nonexistent-repo-xyz', '/tmp', 'a', 'b');
|
||||
assert.equal(r.ok, true);
|
||||
});
|
||||
|
||||
test('globToRegExp / matchesAnyGlob:* 单层、** 跨层、? 单字符、目录式', () => {
|
||||
assert.ok(globToRegExp('src/*.ts').test('src/a.ts'));
|
||||
assert.ok(!globToRegExp('src/*.ts').test('src/sub/a.ts')); // * 不跨 /
|
||||
assert.ok(globToRegExp('src/**').test('src/sub/deep/a.ts')); // ** 跨 /
|
||||
assert.ok(globToRegExp('src/').test('src/anything/x.ts')); // 目录式 → 其下全部
|
||||
assert.ok(globToRegExp('a?.ts').test('ab.ts'));
|
||||
assert.ok(!globToRegExp('a?.ts').test('abc.ts'));
|
||||
assert.ok(globToRegExp('file.name.js').test('file.name.js')); // . 字面量
|
||||
assert.ok(!globToRegExp('file.name.js').test('fileXname.js'));
|
||||
assert.ok(matchesAnyGlob('lib/x.ts', ['src/**', 'lib/*.ts']));
|
||||
assert.ok(!matchesAnyGlob('test/x.ts', ['src/**', 'lib/*.ts']));
|
||||
});
|
||||
|
||||
test('scopeFileGate:改动越界文件 → 拦截;范围内 → 通过;空声明 → 跳过', async () => {
|
||||
const repo = mkdtempSync(join(tmpdir(), 'maestro-scope-'));
|
||||
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, 'base.txt'), 'base\n');
|
||||
g(['add', '-A']); g(['commit', '-qm', 'base']);
|
||||
g(['checkout', '-q', '-b', 'feature']);
|
||||
execFileSync('mkdir', ['-p', join(repo, 'src')]);
|
||||
writeFileSync(join(repo, 'src', 'a.ts'), 'a\n');
|
||||
writeFileSync(join(repo, 'other.txt'), 'o\n'); // 越界文件
|
||||
g(['add', '-A']); g(['commit', '-qm', 'feat']);
|
||||
|
||||
try {
|
||||
// 声明只允许 src/**,但改了 other.txt → 拦截
|
||||
const over = await scopeFileGate(repo, 'feature', 'main', ['src/**']);
|
||||
assert.equal(over.ok, false);
|
||||
assert.deepEqual(over.outside, ['other.txt']);
|
||||
assert.match(over.error!, /声明范围外/);
|
||||
|
||||
// 声明覆盖全部改动 → 通过
|
||||
const ok = await scopeFileGate(repo, 'feature', 'main', ['src/**', 'other.txt']);
|
||||
assert.equal(ok.ok, true);
|
||||
assert.deepEqual(ok.outside, []);
|
||||
|
||||
// 空声明 → 跳过(不限范围)
|
||||
const skip = await scopeFileGate(repo, 'feature', 'main', []);
|
||||
assert.equal(skip.ok, true);
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('scopeFileGate:git 出错 → 不拦截', async () => {
|
||||
const r = await scopeFileGate('/nonexistent-repo-xyz', 'a', 'b', ['src/**']);
|
||||
assert.equal(r.ok, true);
|
||||
});
|
||||
|
||||
@@ -31,11 +31,14 @@ test('迁移:旧 projects 表缺新列 → openDb 补齐 + createProject 不
|
||||
const db = openDb(file);
|
||||
const cols = (db.prepare(`PRAGMA table_info(projects)`).all() as Array<{ name: string }>).map((c) => c.name);
|
||||
for (const c of [
|
||||
'checks', 'auto_approve_plan', 'auto_approve_exec', 'agent_rules',
|
||||
'checks', 'auto_approve_plan', 'auto_approve_exec', 'agent_rules', 'models',
|
||||
'budget_usd', 'budget_period', 'max_retries', 'timeout_ms', 'last_sync_at', 'logo', 'sort_order',
|
||||
]) {
|
||||
assert.ok(cols.includes(c), `应补列 ${c}`);
|
||||
}
|
||||
// tasks.scope_files 也走 ensureColumn 迁移
|
||||
const taskCols = (db.prepare(`PRAGMA table_info(tasks)`).all() as Array<{ name: string }>).map((c) => c.name);
|
||||
assert.ok(taskCols.includes('scope_files'), '应补 tasks.scope_files 列');
|
||||
db.close();
|
||||
|
||||
// Store 在该旧库上建项目(INSERT 引用 checks/auto_approve_* 列)应成功
|
||||
|
||||
@@ -112,3 +112,12 @@ test('lastRunErrorOf 取最近一次失败 run 的错误(任意 kind)', () =
|
||||
assert.equal(s.lastRunErrorOf(t.id), '第一次失败:编译错误');
|
||||
s.close();
|
||||
});
|
||||
|
||||
test('buildPrompt:task.scopeFiles 非空 → 注入声明文件范围约束;为空 → 不注入', () => {
|
||||
const withScope = buildPrompt(fakeTask({ scopeFiles: ['src/foo/**', 'lib/a.ts'] }));
|
||||
assert.match(withScope, /声明的改动文件范围/);
|
||||
assert.match(withScope, /src\/foo\/\*\*/);
|
||||
assert.match(withScope, /越界会被硬闸拦截/);
|
||||
const noScope = buildPrompt(fakeTask({ scopeFiles: null }));
|
||||
assert.doesNotMatch(noScope, /声明的改动文件范围/);
|
||||
});
|
||||
|
||||
+70
-50
@@ -1,14 +1,14 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { pickModel, isModelError, pickFallbackModel, MODEL_FALLBACK_CHAIN } from '../src/executor/models.js';
|
||||
import type { Project, Task } from '../src/model/types.js';
|
||||
import { pickModel, resolveModel, isModelError, pickFallbackModel, MODEL_FALLBACK_CHAIN, DEFAULT_MODEL } from '../src/executor/models.js';
|
||||
import type { Project, Task, ProjectModels } from '../src/model/types.js';
|
||||
import type { Complexity } from '../src/model/complexity.js';
|
||||
|
||||
function fakeTask(complexity: Complexity): Task {
|
||||
return { complexity } as Task;
|
||||
}
|
||||
function fakeProject(model: string | null = null): Project {
|
||||
return { model } as Project;
|
||||
function fakeProject(over: Partial<Project> = {}): Project {
|
||||
return { model: null, models: null, ...over } as Project;
|
||||
}
|
||||
|
||||
/** 临时设置 env,跑完恢复(避免污染其他用例) */
|
||||
@@ -31,58 +31,79 @@ function withEnv(vars: Record<string, string>, fn: () => void): void {
|
||||
const ENV_KEYS = [
|
||||
'MAESTRO_MODEL_EASY', 'MAESTRO_MODEL_MEDIUM', 'MAESTRO_MODEL_HARD',
|
||||
'MAESTRO_MODEL_REVIEW_EASY', 'MAESTRO_MODEL_REVIEW_MEDIUM', 'MAESTRO_MODEL_REVIEW_HARD',
|
||||
'MAESTRO_MODEL_PLAN_EASY', 'MAESTRO_MODEL_PLAN_MEDIUM', 'MAESTRO_MODEL_PLAN_HARD',
|
||||
];
|
||||
const clearEnv = (): Record<string, string> => Object.fromEntries(ENV_KEYS.map((k) => [k, '']));
|
||||
|
||||
test('pickModel:复杂度映射默认值(executor / reviewer)', () => {
|
||||
// 防止外部 env 干扰:先清空再断言默认
|
||||
withEnv(Object.fromEntries(ENV_KEYS.map((k) => [k, ''])), () => {
|
||||
test('默认档:所有角色 × 所有复杂度统一 opus-4.8(无 project/env 配置)', () => {
|
||||
withEnv(clearEnv(), () => {
|
||||
const p = fakeProject();
|
||||
assert.equal(pickModel(fakeTask('easy'), p, 'executor'), 'claude-sonnet-4-6');
|
||||
assert.equal(pickModel(fakeTask('medium'), p, 'executor'), 'claude-opus-4-8');
|
||||
assert.equal(pickModel(fakeTask('hard'), p, 'executor'), 'claude-opus-4-8');
|
||||
// reviewer 三档统一 fable-5(复审是质量关口,用最强档保严格度)
|
||||
assert.equal(pickModel(fakeTask('easy'), p, 'reviewer'), 'claude-fable-5');
|
||||
assert.equal(pickModel(fakeTask('medium'), p, 'reviewer'), 'claude-fable-5');
|
||||
assert.equal(pickModel(fakeTask('hard'), p, 'reviewer'), 'claude-fable-5');
|
||||
// planner hard 拆解最烧脑 → fable-5;medium=opus;easy=sonnet
|
||||
assert.equal(pickModel(fakeTask('hard'), p, 'planner'), 'claude-fable-5');
|
||||
assert.equal(pickModel(fakeTask('medium'), p, 'planner'), 'claude-opus-4-8');
|
||||
assert.equal(pickModel(fakeTask('easy'), p, 'planner'), 'claude-sonnet-4-6');
|
||||
});
|
||||
});
|
||||
|
||||
test('pickModel:project.model executor 最优先,但 reviewer/conflict 不受 project.model 覆盖', () => {
|
||||
withEnv(Object.fromEntries(ENV_KEYS.map((k) => [k, ''])), () => {
|
||||
const p = fakeProject('claude-opus-4-6');
|
||||
for (const c of ['easy', 'medium', 'hard'] as const) {
|
||||
// executor 受 project.model 覆盖
|
||||
assert.equal(pickModel(fakeTask(c), p, 'executor'), 'claude-opus-4-6');
|
||||
// reviewer 不受 project.model 覆盖,始终用 fable-5(保自审独立性)
|
||||
assert.equal(pickModel(fakeTask(c), p, 'reviewer'), 'claude-fable-5');
|
||||
// conflict 固定 fable-5(解冲突恒用最强档)
|
||||
assert.equal(pickModel(fakeTask(c), p, 'conflict'), 'claude-fable-5');
|
||||
assert.equal(DEFAULT_MODEL, 'claude-opus-4-8');
|
||||
for (const role of ['executor', 'planner', 'reviewer', 'conflict'] as const) {
|
||||
for (const c of ['easy', 'medium', 'hard'] as Complexity[]) {
|
||||
assert.equal(pickModel(fakeTask(c), p, role), 'claude-opus-4-8', `${role}/${c}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('pickModel:env 覆盖默认值,executor/reviewer 各用各的 env', () => {
|
||||
withEnv({
|
||||
MAESTRO_MODEL_EASY: 'claude-haiku-4-5',
|
||||
MAESTRO_MODEL_REVIEW_EASY: 'claude-sonnet-4-6', // reviewer env 可单独覆盖
|
||||
}, () => {
|
||||
const p = fakeProject();
|
||||
assert.equal(pickModel(fakeTask('easy'), p, 'executor'), 'claude-haiku-4-5');
|
||||
// reviewer easy 被 MAESTRO_MODEL_REVIEW_EASY 覆盖
|
||||
assert.equal(pickModel(fakeTask('easy'), p, 'reviewer'), 'claude-sonnet-4-6');
|
||||
// reviewer hard 无 env → 默认 fable-5
|
||||
assert.equal(pickModel(fakeTask('hard'), p, 'reviewer'), 'claude-fable-5');
|
||||
assert.equal(pickModel(fakeTask('hard'), p, 'executor'), 'claude-opus-4-8');
|
||||
test('project.models:按角色覆盖默认(字符串=全复杂度统一)', () => {
|
||||
withEnv(clearEnv(), () => {
|
||||
const models: ProjectModels = { reviewer: 'claude-fable-5', conflict: 'claude-fable-5' };
|
||||
const p = fakeProject({ models });
|
||||
// reviewer/conflict 被项目级 models 覆盖成 fable-5(含三档)
|
||||
for (const c of ['easy', 'medium', 'hard'] as Complexity[]) {
|
||||
assert.equal(pickModel(fakeTask(c), p, 'reviewer'), 'claude-fable-5');
|
||||
assert.equal(pickModel(fakeTask(c), p, 'conflict'), 'claude-fable-5');
|
||||
}
|
||||
// executor 未配 → 仍默认 opus
|
||||
assert.equal(pickModel(fakeTask('easy'), p, 'executor'), 'claude-opus-4-8');
|
||||
});
|
||||
});
|
||||
|
||||
test('pickModel:env 为空白字符串视为未设置', () => {
|
||||
withEnv({ MAESTRO_MODEL_MEDIUM: ' ' }, () => {
|
||||
assert.equal(pickModel(fakeTask('medium'), fakeProject(), 'executor'), 'claude-opus-4-8');
|
||||
test('project.models:按复杂度分档(对象形式),缺档回落默认', () => {
|
||||
withEnv(clearEnv(), () => {
|
||||
const models: ProjectModels = { executor: { easy: 'claude-sonnet-4-6', hard: 'claude-fable-5' } };
|
||||
const p = fakeProject({ models });
|
||||
assert.equal(pickModel(fakeTask('easy'), p, 'executor'), 'claude-sonnet-4-6');
|
||||
assert.equal(pickModel(fakeTask('hard'), p, 'executor'), 'claude-fable-5');
|
||||
// medium 未在对象里 → 回落默认 opus
|
||||
assert.equal(pickModel(fakeTask('medium'), p, 'executor'), 'claude-opus-4-8');
|
||||
});
|
||||
});
|
||||
|
||||
test('优先级:project.models > 旧 project.model > env > 默认', () => {
|
||||
withEnv({ ...clearEnv(), MAESTRO_MODEL_MEDIUM: 'claude-haiku-4-5' }, () => {
|
||||
// models.executor 覆盖一切
|
||||
const p1 = fakeProject({ model: 'claude-opus-4-6', models: { executor: 'claude-sonnet-4-6' } });
|
||||
assert.equal(pickModel(fakeTask('medium'), p1, 'executor'), 'claude-sonnet-4-6');
|
||||
// 无 models → 旧 project.model 覆盖 env
|
||||
const p2 = fakeProject({ model: 'claude-opus-4-6' });
|
||||
assert.equal(pickModel(fakeTask('medium'), p2, 'executor'), 'claude-opus-4-6');
|
||||
// 无 models / 无 project.model → env 覆盖默认
|
||||
const p3 = fakeProject();
|
||||
assert.equal(pickModel(fakeTask('medium'), p3, 'executor'), 'claude-haiku-4-5');
|
||||
});
|
||||
});
|
||||
|
||||
test('旧 project.model 不覆盖 reviewer/conflict(保自审独立性),但 project.models 可以', () => {
|
||||
withEnv(clearEnv(), () => {
|
||||
const p = fakeProject({ model: 'claude-opus-4-6' });
|
||||
// project.model 只作用 executor/planner
|
||||
assert.equal(pickModel(fakeTask('hard'), p, 'executor'), 'claude-opus-4-6');
|
||||
assert.equal(pickModel(fakeTask('hard'), p, 'planner'), 'claude-opus-4-6');
|
||||
// reviewer/conflict 不受 project.model 影响 → 仍默认 opus
|
||||
assert.equal(pickModel(fakeTask('hard'), p, 'reviewer'), 'claude-opus-4-8');
|
||||
assert.equal(pickModel(fakeTask('hard'), p, 'conflict'), 'claude-opus-4-8');
|
||||
// 但显式 project.models.reviewer 可以覆盖
|
||||
const p2 = fakeProject({ model: 'claude-opus-4-6', models: { reviewer: 'claude-sonnet-4-6' } });
|
||||
assert.equal(pickModel(fakeTask('hard'), p2, 'reviewer'), 'claude-sonnet-4-6');
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveModel:env 空白字符串视为未设置', () => {
|
||||
withEnv({ ...clearEnv(), MAESTRO_MODEL_MEDIUM: ' ' }, () => {
|
||||
assert.equal(resolveModel(fakeProject(), 'executor', 'medium'), 'claude-opus-4-8');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,10 +124,9 @@ test('isModelError:模型不可用类错误才触发回退', () => {
|
||||
assert.ok(!isModelError('model produced empty output')); // 提到 model 但不是可用性错误
|
||||
});
|
||||
|
||||
test('pickFallbackModel:回退链取下一档(循环)', () => {
|
||||
// 链:fable → opus → sonnet → fable(循环,fable 置链首=优先最强档)
|
||||
assert.deepEqual([...MODEL_FALLBACK_CHAIN], ['claude-fable-5', 'claude-opus-4-8', 'claude-sonnet-4-6']);
|
||||
assert.equal(pickFallbackModel('claude-fable-5'), 'claude-opus-4-8');
|
||||
test('pickFallbackModel:回退链取下一档(opus→sonnet→fable→opus,opus 置链首=默认档优先)', () => {
|
||||
assert.deepEqual([...MODEL_FALLBACK_CHAIN], ['claude-opus-4-8', 'claude-sonnet-4-6', 'claude-fable-5']);
|
||||
assert.equal(pickFallbackModel('claude-opus-4-8'), 'claude-sonnet-4-6');
|
||||
assert.equal(pickFallbackModel('claude-sonnet-4-6'), 'claude-fable-5');
|
||||
assert.equal(pickFallbackModel('claude-fable-5'), 'claude-opus-4-8');
|
||||
});
|
||||
|
||||
@@ -272,3 +272,10 @@ test('parseDecompose:取最后一个 json 块;非法/缺失 → null', () =>
|
||||
assert.equal(ok.subtasks.length, 1, '过滤空标题与非法复杂度');
|
||||
assert.equal(ok.subtasks[0].title, 'a');
|
||||
});
|
||||
|
||||
test('parseDecompose:解析 files(声明文件范围),去空白/空项;缺省不带 files', () => {
|
||||
const r = parseDecompose('```json\n{"plan":"p","subtasks":[{"title":"a","complexity":"easy","files":[" src/** ","","lib/x.ts"]},{"title":"b","complexity":"easy"}]}\n```');
|
||||
assert.ok(r);
|
||||
assert.deepEqual(r.subtasks[0].files, ['src/**', 'lib/x.ts']);
|
||||
assert.equal(r.subtasks[1].files, undefined, '无 files 字段 → 不带(不限范围)');
|
||||
});
|
||||
|
||||
@@ -20,6 +20,47 @@ test('project + task creation, complexity routes initial status', () => {
|
||||
s.close();
|
||||
});
|
||||
|
||||
test('patchProject models:持久化 + 读回(字符串/对象形式,非法值丢弃,null 清空)', () => {
|
||||
const s = freshStore();
|
||||
const p = s.createProject({ name: 'mdl', repoPath: '/tmp/mdl-' + Math.random() });
|
||||
assert.equal(p.models, null);
|
||||
|
||||
const up = s.patchProject(p.id, {
|
||||
models: {
|
||||
executor: { easy: 'claude-sonnet-4-6', hard: 'claude-opus-4-8' },
|
||||
reviewer: 'claude-fable-5',
|
||||
// @ts-expect-error 测试非法角色被丢弃
|
||||
bogus: 'x',
|
||||
},
|
||||
});
|
||||
assert.deepEqual(up.models, {
|
||||
executor: { easy: 'claude-sonnet-4-6', hard: 'claude-opus-4-8' },
|
||||
reviewer: 'claude-fable-5',
|
||||
});
|
||||
// 读回(走 mapper)一致
|
||||
assert.deepEqual(s.getProject(p.id)!.models, up.models);
|
||||
|
||||
// null 清空
|
||||
const cleared = s.patchProject(p.id, { models: null });
|
||||
assert.equal(cleared.models, null);
|
||||
s.close();
|
||||
});
|
||||
|
||||
test('task scopeFiles:create + patch 持久化与读回,去空白/空数组→null', () => {
|
||||
const s = freshStore();
|
||||
const p = s.createProject({ name: 'scp', repoPath: '/tmp/scp-' + Math.random() });
|
||||
const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy', scopeFiles: [' src/** ', '', 'lib/a.ts'] });
|
||||
assert.deepEqual(t.scopeFiles, ['src/**', 'lib/a.ts']); // 去空白 + 丢空项
|
||||
assert.deepEqual(s.getTask(t.id)!.scopeFiles, ['src/**', 'lib/a.ts']);
|
||||
|
||||
const patched = s.patchTask(t.id, { scopeFiles: ['only.ts'] });
|
||||
assert.deepEqual(patched.scopeFiles, ['only.ts']);
|
||||
|
||||
const emptied = s.patchTask(t.id, { scopeFiles: [] });
|
||||
assert.equal(emptied.scopeFiles, null); // 空数组 → null(不限范围)
|
||||
s.close();
|
||||
});
|
||||
|
||||
test('Easy 路径:写操作 → ready → nextExecutable 领取', () => {
|
||||
const s = freshStore();
|
||||
const p = s.createProject({ name: 'e', repoPath: '/tmp/e-' + Math.random() });
|
||||
|
||||
Reference in New Issue
Block a user