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; 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 { 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)。 * 流式消息逐行写入 /.jsonl;返回 ok/transcriptRef/sessionId/finalText/error。 * 不抛错:一切失败都折叠进 { ok: false, error }。 */ export async function runTask(task: Task, project: Project, worktree: WorktreeInfo, runId: string): Promise { 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; /** 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 { 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 { // 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 }; }