59ce391ecc
① 模型可配置(项目维度,默认 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>
318 lines
16 KiB
TypeScript
318 lines
16 KiB
TypeScript
import type { Project, Task, UsageTokens } from '../model/types.js';
|
||
import { git, type WorktreeInfo } from './worktree.js';
|
||
import { runClaude } from './cc.js';
|
||
import { pickModel } from './models.js';
|
||
import { readGlobalAgentRules } from './protocol.js';
|
||
|
||
export { transcriptDir } from './cc.js';
|
||
|
||
export interface RunnerResult {
|
||
ok: boolean;
|
||
transcriptRef: string | null;
|
||
sessionId: string | null;
|
||
/** SDK result 消息的文本(执行者自述,传给 reviewer);mock/失败时可缺省 */
|
||
finalText?: string | null;
|
||
/** 实际使用的模型(发生回退时为回退模型) */
|
||
modelUsed?: string;
|
||
/** 本次 CC 的 token 用量(供预算成本折算) */
|
||
usage?: UsageTokens;
|
||
error?: string;
|
||
}
|
||
|
||
/** 执行函数签名(orchestrator 依赖注入点;测试传 mock,生产传 runTask) */
|
||
export type RunnerFn = (task: Task, project: Project, worktree: WorktreeInfo, runId: string) => Promise<RunnerResult>;
|
||
|
||
const DEFAULT_MAX_TURNS = 100;
|
||
/** 若项目未配置 timeoutMs 时的全局兜底超时(30min) */
|
||
const DEFAULT_TIMEOUT_MS = 30 * 60_000;
|
||
|
||
/**
|
||
* L4+L2 记忆注入:prompt 头部规范段。全局规范(L4,maestro 自维护 agent-global.md)在前,
|
||
* 项目规范(L2,project.agentRules)在后。两者皆空 → 空数组。
|
||
*/
|
||
export function rulesHeaderLines(project?: Project, globalRules?: string | null): string[] {
|
||
const out: string[] = [];
|
||
const g = globalRules?.trim();
|
||
if (g) out.push('## 全局执行规范(所有任务通用,必须遵守)', g, '');
|
||
const pr = project?.agentRules?.trim();
|
||
if (pr) out.push('## 项目规范(本项目专属,必须遵守)', pr, '');
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* L3 拆解背景注入:若任务带 context(父任务拆解意图 + 兄弟任务状态),生成背景段;否则空数组。
|
||
* 由 daemon 在 claimOne 时把 store.taskContextOf 填进 task.context 下发(仅 executor 子任务)。
|
||
*/
|
||
function contextLines(task: Task): string[] {
|
||
const ctx = task.context;
|
||
if (!ctx) return [];
|
||
const out: string[] = [];
|
||
const plan = ctx.parentPlan?.trim();
|
||
const sibs = ctx.siblings ?? [];
|
||
if (!plan && sibs.length === 0) return [];
|
||
out.push('## 拆解背景(你是某个大任务拆出的子任务)');
|
||
if (plan) out.push('父任务的拆解意图:', plan, '');
|
||
if (sibs.length) {
|
||
out.push('同批兄弟子任务(注意衔接,不要重复或冲突):');
|
||
for (const s of sibs) out.push(`- 「${s.title}」(${s.complexity},状态:${s.status})`);
|
||
out.push('');
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* L1 记忆注入:若任务带「上次失败原因」(重试任务),生成一段提醒;否则空数组。
|
||
* 由 daemon 在 claimOne 时把 store.lastRunErrorOf 填进 task.lastRunError 下发(见 orchestrator)。
|
||
*/
|
||
function lastErrorLines(task: Task): string[] {
|
||
const err = task.lastRunError?.trim();
|
||
if (!err) return [];
|
||
return [
|
||
'## 上次失败原因(这是重试任务,请勿重蹈覆辙)',
|
||
'上一次执行本任务的 agent 失败,错误信息如下。请先理解失败根因,针对性规避:',
|
||
'```',
|
||
err.length > 2000 ? err.slice(0, 2000) + '\n…(已截断)' : err,
|
||
'```',
|
||
'',
|
||
];
|
||
}
|
||
|
||
/** 声明文件范围约束(task.scopeFiles 非空时注入):改动越界文件会被硬闸拦截、任务打回。 */
|
||
function scopeConstraintLines(task: Task): string[] {
|
||
const scope = task.scopeFiles?.filter((s) => s.trim());
|
||
if (!scope || scope.length === 0) return [];
|
||
return [
|
||
`- 本任务声明的改动文件范围(仅允许改动这些 glob/路径,越界会被硬闸拦截并打回):${scope.map((s) => `\`${s}\``).join('、')}`,
|
||
];
|
||
}
|
||
|
||
/** 组装任务提示词:标题 + 全局/项目规范(L4/L2) + operations/spec 全文 + 上次失败(L1) + 执行约束 */
|
||
export function buildPrompt(task: Task, project?: Project, globalRules?: string | null): string {
|
||
const body = task.operations ?? task.spec ?? task.plan ?? '';
|
||
return [
|
||
`# 任务:${task.title}`,
|
||
`任务 ID:${task.id}`,
|
||
'',
|
||
...rulesHeaderLines(project, globalRules),
|
||
...contextLines(task),
|
||
'## 任务内容',
|
||
body || '(无详细说明,按标题完成)',
|
||
'',
|
||
...lastErrorLines(task),
|
||
'## 执行约束(必须遵守)',
|
||
'- 只在当前工作目录(git worktree)内改动文件,不得读写或修改 worktree 之外的任何文件。',
|
||
...scopeConstraintLines(task),
|
||
`- 完成后用 \`git add -A && git commit\` 提交全部改动,commit message 必须包含任务 ID「${task.id}」。`,
|
||
'- 不得执行 git push / git merge / 切换分支,当前分支就是你的工作分支。',
|
||
'- 不要发布、部署或执行任何有外部副作用的操作。',
|
||
'- 若项目有测试体系且改动可测试,请补充并实际运行相关测试(npm/go/pytest/shellcheck 等已授权),并在最终回复中说明测试命令与结果。',
|
||
'- 最终回复请简要总结:做了什么、怎么做的、测试结果。',
|
||
].join('\n');
|
||
}
|
||
|
||
/** CC 没提交时由 runner 兜底:git add -A + commit(无改动则跳过) */
|
||
async function ensureCommitted(dir: string, task: Task): Promise<void> {
|
||
const status = (await git(dir, ['status', '--porcelain'])).trim();
|
||
if (!status) return;
|
||
await git(dir, ['add', '-A']);
|
||
await git(dir, ['-c', 'user.name=maestro', '-c', 'user.email=maestro@local', 'commit', '-m', `maestro(${task.id}): ${task.title}`]);
|
||
}
|
||
|
||
/**
|
||
* 在 worktree 内用 Claude Agent SDK 起 headless Claude Code 执行任务。
|
||
* 模型按复杂度选择(project.model 最优先),不可用时自动回退重试(见 cc.ts / models.ts)。
|
||
* 流式消息逐行写入 <transcriptDir>/<runId>.jsonl;返回 ok/transcriptRef/sessionId/finalText/error。
|
||
* 不抛错:一切失败都折叠进 { ok: false, error }。
|
||
*/
|
||
export async function runTask(task: Task, project: Project, worktree: WorktreeInfo, runId: string): Promise<RunnerResult> {
|
||
const model = pickModel(task, project, 'executor');
|
||
const timeoutMs = project.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||
const cc = await runClaude({
|
||
prompt: buildPrompt(task, project, readGlobalAgentRules()),
|
||
cwd: worktree.dir,
|
||
model,
|
||
runId,
|
||
maxTurns: DEFAULT_MAX_TURNS,
|
||
timeoutMs,
|
||
permissionMode: 'acceptEdits', // worktree 内自动接受编辑
|
||
allowedTools: [
|
||
'Read', 'Edit', 'Write', 'Glob', 'Grep',
|
||
// git:提交所需最小面(不含 push)
|
||
'Bash(git status:*)', 'Bash(git diff:*)', 'Bash(git log:*)',
|
||
'Bash(git add:*)', 'Bash(git commit:*)',
|
||
// 测试/构建:让执行任务自己跑测试(仍关在 worktree 内,无 push 无包发布)
|
||
'Bash(npm test:*)', 'Bash(npm run:*)', 'Bash(npm ci:*)', 'Bash(npm install:*)',
|
||
'Bash(npx:*)', 'Bash(node:*)',
|
||
'Bash(go build:*)', 'Bash(go test:*)', 'Bash(go vet:*)', 'Bash(go mod:*)', 'Bash(go run:*)',
|
||
'Bash(shellcheck:*)', 'Bash(bash -n:*)', 'Bash(make:*)',
|
||
'Bash(python3 -m pytest:*)', 'Bash(pytest:*)',
|
||
],
|
||
});
|
||
|
||
if (!cc.ok) {
|
||
return { ok: false, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: null, modelUsed: cc.modelUsed, usage: cc.usage, error: cc.error ?? '未知错误' };
|
||
}
|
||
|
||
// 兜底:CC 没 commit 时由 runner 代为提交
|
||
try {
|
||
await ensureCommitted(worktree.dir, task);
|
||
} catch (e) {
|
||
return { ok: false, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: cc.finalText || null, modelUsed: cc.modelUsed, usage: cc.usage, error: `兜底提交失败:${(e as Error).message}` };
|
||
}
|
||
return { ok: true, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: cc.finalText || null, modelUsed: cc.modelUsed, usage: cc.usage };
|
||
}
|
||
|
||
// ───────────────────────── planner(拆解 Hard / 写方案 Medium)─────────────────────────
|
||
|
||
export type PlanKind = 'spec' | 'decompose';
|
||
|
||
export interface PlannerResult {
|
||
ok: boolean;
|
||
transcriptRef: string | null;
|
||
sessionId: string | null;
|
||
finalText: string | null; // CC 的最终文本:spec=方案正文;decompose=分析 + 末尾 fenced JSON
|
||
modelUsed?: string;
|
||
usage?: UsageTokens;
|
||
error?: string;
|
||
}
|
||
|
||
/** 测试注入点(pipeline 的 planner 分支用) */
|
||
export type PlannerFn = (task: Task, project: Project, kind: PlanKind, runId: string) => Promise<PlannerResult>;
|
||
|
||
/** planner 提示词:只读代码库,spec=写改动方案;decompose=拆子任务并在末尾输出 fenced JSON。 */
|
||
export function buildPlannerPrompt(task: Task, kind: PlanKind, project?: Project, globalRules?: string | null): string {
|
||
const head = [`# 任务:${task.title}`, `任务 ID:${task.id}`, '', ...rulesHeaderLines(project, globalRules)];
|
||
const errLines = lastErrorLines(task);
|
||
if (kind === 'spec') {
|
||
if (task.spec) head.push('## 现有方案草稿(可改进/替换)', task.spec, '');
|
||
return [
|
||
...head,
|
||
...errLines,
|
||
'## 你的角色:方案作者(只读,不改任何文件)',
|
||
'只读这个代码库,为上述任务写一份「具体改动方案」。不要编辑文件、不要执行有副作用的命令。',
|
||
'## 输出(markdown 正文)',
|
||
'## 改动(要改哪些文件/模块、怎么改)',
|
||
'## 为什么(这么做的理由、取舍)',
|
||
'## 验收(怎么算完成、怎么验证)',
|
||
'这份方案将提交给人审;通过后才会有执行 agent 按它改代码。直接输出方案正文即可。',
|
||
].join('\n');
|
||
}
|
||
if (task.plan) head.push('', '## 现有分析草稿(可改进/替换)', task.plan);
|
||
return [
|
||
...head, '',
|
||
...errLines,
|
||
'## 你的角色:任务拆解者(只读,不改任何文件)',
|
||
`当前任务层级 depth=${task.depth ?? 1}(根=1),最多拆到 depth=4(剩余可拆层数 ${Math.max(0, 4 - (task.depth ?? 1))} 层)。`,
|
||
'只读这个代码库,分析上述(复杂)任务,拆成 2–6 个更小、可独立交付的子任务。不要编辑文件、不要执行有副作用的命令。',
|
||
'每个子任务标注:',
|
||
'- complexity:easy(单文件机械改动/无设计)、medium(需方案、跨几处)、hard(仍需进一步拆解,仅剩余可拆层数>0时才能用)',
|
||
'- priority:0=P0最紧急 / 1=P1中 / 2=P2最低',
|
||
'- deps:依赖本列表中其他子任务的 0-based 序号(无依赖填 [])',
|
||
'- files:该子任务预计改动的文件范围(glob/路径,如 ["src/foo/**","lib/a.ts"])。尽量精确——执行时改动越界文件会被硬闸拦截。无法确定就留空数组(不限范围)。',
|
||
'',
|
||
'## 输出格式(重要,必须严格遵守)',
|
||
'**第一步**:先输出一个 Markdown 表格总览:',
|
||
'| # | 子任务标题 | 复杂度 | 优先级 | 依赖序号 | 文件范围 |',
|
||
'|---|---|---|---|---|---|',
|
||
'(每行一个子任务,依赖序号填 0-based 序号列表如 "0,1" 或留空;文件范围填预计改动的路径/glob 或留空)',
|
||
'',
|
||
'**第二步**:再写一段分析与拆解理由。',
|
||
'',
|
||
'**第三步**:在【最后】输出唯一一个 ```json 代码块,严格格式:',
|
||
'```json',
|
||
'{"plan":"一句话分析与拆解理由","subtasks":[{"title":"子任务标题","complexity":"easy","priority":1,"deps":[],"files":["src/foo/**"]}]}',
|
||
'```',
|
||
'subtasks 至少 1 个;complexity 只能是 easy|medium|hard;deps 是 0-based 序号数组;files 是文件 glob/路径数组(可空);除这个 JSON 块外不要再写其它 ``` 代码块。',
|
||
].join('\n');
|
||
}
|
||
|
||
/**
|
||
* 起 headless CC 跑一次 planner:**只读**跑在 project.repoPath(不建 worktree、不改文件)。
|
||
* 模型用 planner 角色(默认 opus)。不抛错,失败折叠进 { ok:false, error }。
|
||
*/
|
||
export async function runPlanner(task: Task, project: Project, kind: PlanKind, runId: string): Promise<PlannerResult> {
|
||
const model = pickModel(task, project, 'planner');
|
||
const timeoutMs = project.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||
const cc = await runClaude({
|
||
prompt: buildPlannerPrompt(task, kind, project, readGlobalAgentRules()),
|
||
cwd: project.repoPath, // 只读跑在主仓(planner 不改文件,无需 worktree)
|
||
model,
|
||
runId,
|
||
maxTurns: task.complexity === 'hard' ? 90 : task.complexity === 'medium' ? 60 : 40,
|
||
timeoutMs,
|
||
permissionMode: 'acceptEdits', // planner 只读,不会触发编辑;保持工具流不被 prompt 卡住
|
||
allowedTools: ['Read', 'Glob', 'Grep', 'Bash(git log:*)', 'Bash(git diff:*)', 'Bash(git show:*)'], // 纯只读:无 Edit/Write,零副作用
|
||
});
|
||
return { ok: cc.ok, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: cc.finalText || null, modelUsed: cc.modelUsed, usage: cc.usage, error: cc.error };
|
||
}
|
||
|
||
/** 解冲突任务的提示词:worker 已在 worktree 内触发 git merge 制造冲突态,CC 负责逐个解决。 */
|
||
export function buildConflictPrompt(task: Task, conflictFiles: string[], project?: Project, globalRules?: string | null): string {
|
||
return [
|
||
`# 合并冲突解决:${task.title}`,
|
||
`任务 ID:${task.id}`,
|
||
'',
|
||
...rulesHeaderLines(project, globalRules),
|
||
'## 背景',
|
||
'你的工作目录(git worktree)已执行 `git merge` 并产生合并冲突。你的任务是解决全部冲突,保留双方改动的意图,然后提交。',
|
||
'',
|
||
'## 冲突文件',
|
||
conflictFiles.length ? conflictFiles.map((f) => `- ${f}`).join('\n') : '(以 git status 为准)',
|
||
'',
|
||
...lastErrorLines(task),
|
||
'## 执行约束(必须遵守)',
|
||
'- 逐个打开冲突文件,理解 <<<<<<< HEAD 与 >>>>>>> 两侧的意图,保留双方有价值的改动,不得丢弃任一方的实质内容。',
|
||
'- 解决完所有冲突后,用 `git add -A` 暂存,然后 `git commit`(直接用默认合并 commit message,不要修改)。',
|
||
'- 不得执行 git push / git merge(merge 已经发生)/ 切换分支。',
|
||
'- 若项目有测试体系且改动可测试,解决冲突后请运行相关测试并在最终回复中说明结果。',
|
||
'',
|
||
'## 最终回复格式',
|
||
'请简要说明:',
|
||
'1. 解决了哪些文件的冲突',
|
||
'2. 每个文件冲突的取舍策略(保留了哪边的哪些内容)',
|
||
'3. 测试运行结果(如有)',
|
||
].join('\n');
|
||
}
|
||
|
||
/** 在 worktree 内运行解冲突 agent(CC 已看到 git merge 制造的冲突态)。 */
|
||
export async function runConflict(
|
||
task: Task,
|
||
project: Project,
|
||
worktree: WorktreeInfo,
|
||
runId: string,
|
||
conflictFiles: string[],
|
||
): Promise<RunnerResult> {
|
||
// conflict 角色固定用最强模型(见 models.ts pickModel)
|
||
const model = pickModel(task, project, 'conflict' as import('./models.js').ModelRole);
|
||
const timeoutMs = project.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||
const cc = await runClaude({
|
||
prompt: buildConflictPrompt(task, conflictFiles, project, readGlobalAgentRules()),
|
||
cwd: worktree.dir,
|
||
model,
|
||
runId,
|
||
maxTurns: 80,
|
||
timeoutMs,
|
||
permissionMode: 'acceptEdits',
|
||
allowedTools: [
|
||
'Read', 'Edit', 'Write', 'Glob', 'Grep',
|
||
'Bash(git status:*)', 'Bash(git diff:*)', 'Bash(git log:*)',
|
||
'Bash(git add:*)', 'Bash(git commit:*)',
|
||
// 测试命令(同 executor)
|
||
'Bash(npm test:*)', 'Bash(npm run:*)', 'Bash(npm ci:*)',
|
||
'Bash(go build:*)', 'Bash(go test:*)', 'Bash(go vet:*)', 'Bash(go mod:*)',
|
||
'Bash(shellcheck:*)', 'Bash(python3 -m pytest:*)', 'Bash(pytest:*)',
|
||
],
|
||
});
|
||
|
||
if (!cc.ok) {
|
||
return { ok: false, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: null, modelUsed: cc.modelUsed, usage: cc.usage, error: cc.error ?? '解冲突失败' };
|
||
}
|
||
// 兜底提交(CC 应已 commit,但防万一)
|
||
try {
|
||
await ensureCommitted(worktree.dir, task);
|
||
} catch (e) {
|
||
return { ok: false, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: cc.finalText || null, modelUsed: cc.modelUsed, usage: cc.usage, error: `兜底提交失败:${(e as Error).message}` };
|
||
}
|
||
return { ok: true, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: cc.finalText || null, modelUsed: cc.modelUsed, usage: cc.usage };
|
||
}
|