Files
maestro/src/executor/runner.ts
T
wangjia 59ce391ecc 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>
2026-06-25 07:06:32 +08:00

318 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 头部规范段。全局规范(L4maestro 自维护 agent-global.md)在前,
* 项目规范(L2project.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 个更小、可独立交付的子任务。不要编辑文件、不要执行有副作用的命令。',
'每个子任务标注:',
'- complexityeasy(单文件机械改动/无设计)、medium(需方案、跨几处)、hard(仍需进一步拆解,仅剩余可拆层数>0时才能用)',
'- priority0=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|harddeps 是 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 mergemerge 已经发生)/ 切换分支。',
'- 若项目有测试体系且改动可测试,解决冲突后请运行相关测试并在最终回复中说明结果。',
'',
'## 最终回复格式',
'请简要说明:',
'1. 解决了哪些文件的冲突',
'2. 每个文件冲突的取舍策略(保留了哪边的哪些内容)',
'3. 测试运行结果(如有)',
].join('\n');
}
/** 在 worktree 内运行解冲突 agentCC 已看到 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 };
}