merge: planner runs — 编排器自动拆解 hard / 自动写方案 medium [planner; tsk_1dkswD5Ub7gT]

部署 planner 功能(此前实现在 planner 分支、测试绿,未合):编排器把 analyzing/speccing 也当可执行,派 planner run 自动拆解(hard→子任务+plan_review)/写方案(medium→spec+spec_review);in-flight 从 status 泛化为'有 started run';reap 区分 executor/planner。与 main 干净合并,typecheck + 201 测试全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-13 23:10:12 +08:00
12 changed files with 501 additions and 46 deletions
+8 -2
View File
@@ -1,8 +1,8 @@
import type { Project, Task } from '../model/types.js';
import type { Complexity } from '../model/complexity.js';
/** 角色:executor(执行改动)/ reviewer(执行后复审) */
export type ModelRole = 'executor' | 'reviewer';
/** 角色:executor(执行改动)/ reviewer(执行后复审)/ planner(拆解 Hard / 写方案 Medium */
export type ModelRole = 'executor' | 'reviewer' | 'planner';
/** 模型不可用时的回退链(按序取第一个 ≠ 失败模型的,同一 run 内只重试一次) */
export const MODEL_FALLBACK_CHAIN = ['claude-opus-4-8', 'claude-sonnet-4-6'] as const;
@@ -19,6 +19,12 @@ const MODEL_TABLE: Record<ModelRole, Record<Complexity, [env: string, fallback:
medium: ['MAESTRO_MODEL_REVIEW_MEDIUM', 'claude-opus-4-8'],
hard: ['MAESTRO_MODEL_REVIEW_HARD', 'claude-opus-4-8'],
},
// planner:拆解/写方案要质量,统一用 opus 档(project.model 设了仍最优先)
planner: {
easy: ['MAESTRO_MODEL_PLAN_EASY', 'claude-opus-4-8'],
medium: ['MAESTRO_MODEL_PLAN_MEDIUM', 'claude-opus-4-8'],
hard: ['MAESTRO_MODEL_PLAN_HARD', 'claude-opus-4-8'],
},
};
/**
+59 -2
View File
@@ -13,9 +13,9 @@
// 双复审 + setResult(四字段) + transition(exec_review) → emit result
// worker 退出) → emit done
import type { JobSpec, OutboxPayload, ReviewReport } from './protocol.js';
import type { JobSpec, OutboxPayload, ReviewReport, DecomposeResult } from './protocol.js';
import { createWorktree, worktreeDiff, type WorktreeDiff, type WorktreeInfo } from './worktree.js';
import { runTask, type RunnerFn } from './runner.js';
import { runTask, runPlanner, type RunnerFn, type PlannerFn } from './runner.js';
import { runVerify, type VerifyFn } from './verify.js';
import { reviewCode, reviewSecurity, type ReviewerFn } from './reviewer.js';
@@ -27,6 +27,7 @@ export interface PipelineDeps {
verify: VerifyFn;
reviewCode: ReviewerFn; // code reviewdaemon 落成 kind=reviewer 的 run
reviewSecurity: ReviewerFn; // 安全审计(daemon 落成 kind=security 的 run
runPlanner: PlannerFn; // planner(拆解 Hard / 写方案 Medium,只读跑 CC
}
/** 真实依赖(worker 生产用)。 */
@@ -37,6 +38,7 @@ export const realDeps: PipelineDeps = {
verify: runVerify,
reviewCode,
reviewSecurity,
runPlanner,
};
/** emit:把一条 OutboxPayload 交给 outboxworker 传 appendOutbox 包装,测试传收集器)。 */
@@ -74,6 +76,10 @@ async function runOneReview(
* 不抛错(除非 emit 自身抛——那由 worker 顶层兜底)。全程总会 emit 一条终态(failed|result)后再 emit done。
*/
export async function runPipeline(job: JobSpec, deps: PipelineDeps = realDeps, emit: Emit): Promise<void> {
// planner-* 走只读的规划管线(不建 worktree、不改文件、不复审)
if (job.runKind === 'planner-spec' || job.runKind === 'planner-decompose') {
return runPlannerPipeline(job, deps, emit);
}
// 1. 建 worktree(其 dir/branch 应与 job.worktreeDir/branch 一致;以 deps 真建的为准)
const wt = await deps.createWorktree(job.project.repoPath, job.task.id, job.project.defaultBranch);
@@ -118,3 +124,54 @@ export async function runPipeline(job: JobSpec, deps: PipelineDeps = realDeps, e
});
emit({ type: 'done' });
}
/**
* planner 管线(只读):跑一次 CC 写方案 / 拆解,emit spec-result / decompose-result。
* - planner-specCC 输出即方案正文 → emit spec-result{spec}。
* - planner-decompose:解析 CC 输出末尾的 fenced JSON{plan,subtasks} → emit decompose-result;解析失败 → failed。
* CC 失败 / 无输出 → failed。全程总 emit 一条终态后再 emit done。
*/
async function runPlannerPipeline(job: JobSpec, deps: PipelineDeps, emit: Emit): Promise<void> {
const kind = job.runKind === 'planner-spec' ? 'spec' : 'decompose';
emit({ type: 'phase', phase: kind === 'spec' ? 'speccing' : 'decomposing' });
const r = await deps.runPlanner(job.task, job.project, kind, job.runId);
if (!r.ok || !r.finalText) {
emit({ type: 'failed', error: r.error ?? 'planner 无输出', transcriptRef: r.transcriptRef, sessionId: r.sessionId });
emit({ type: 'done' });
return;
}
if (kind === 'spec') {
emit({ type: 'spec-result', spec: r.finalText, transcriptRef: r.transcriptRef, sessionId: r.sessionId });
emit({ type: 'done' });
return;
}
const parsed = parseDecompose(r.finalText);
if (!parsed) {
emit({ type: 'failed', error: 'planner 输出未含合法的子任务 JSON 块', transcriptRef: r.transcriptRef, sessionId: r.sessionId });
emit({ type: 'done' });
return;
}
emit({ type: 'decompose-result', plan: parsed.plan, subtasks: parsed.subtasks, transcriptRef: r.transcriptRef, sessionId: r.sessionId });
emit({ type: 'done' });
}
/** 从 CC 文本里抽取拆解结果:优先取最后一个 ```json 块,回退到任意 ``` 块,再回退到整段当 JSON。非法 → null。 */
export function parseDecompose(text: string): DecomposeResult | null {
const blocks = [...text.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)].map((m) => m[1].trim());
const candidates = blocks.length ? [blocks[blocks.length - 1]] : [text.trim()];
for (const raw of candidates) {
try {
const o = JSON.parse(raw) as { plan?: unknown; subtasks?: unknown };
if (!Array.isArray(o.subtasks)) continue;
const subtasks = o.subtasks
.filter((s): s is { title: string; complexity: 'easy' | 'medium' | 'hard' } =>
!!s && typeof (s as { title?: unknown }).title === 'string' &&
['easy', 'medium', 'hard'].includes((s as { complexity?: unknown }).complexity as string))
.map((s) => ({ title: String(s.title).trim(), complexity: s.complexity }))
.filter((s) => s.title.length > 0);
if (subtasks.length === 0) continue;
return { plan: typeof o.plan === 'string' ? o.plan : '', subtasks };
} catch { /* 试下一个候选 */ }
}
return null;
}
+21
View File
@@ -54,6 +54,9 @@ export function isPlainRunId(runId: string): boolean {
* worker 的唯一输入。daemon 在 spawn 前写 runs/<runId>/job.jsonworker 读它即可执行,
* 【无需访问 DB】。携带完整 Task / Project(均 JSON 可序列化),worker 自行 pickModel/buildPrompt。
*/
/** run 类型:executor(在 worktree 执行改动)/ planner-spec(写 Medium 方案)/ planner-decompose(拆解 Hard */
export type RunKind = 'executor' | 'planner-spec' | 'planner-decompose';
export interface JobSpec {
runId: string;
task: Task;
@@ -61,6 +64,14 @@ export interface JobSpec {
/** 确定性 worktree 路径与分支(daemon 预填;worker 也能由 worktree.ts 算出,传入避免重复) */
worktreeDir: string;
branch: string;
/** run 类型,缺省 executor(向后兼容旧 job)。planner-* 在 repo 内只读跑 CC、不建 worktree。 */
runKind?: RunKind;
}
/** planner-decompose 的拆解产出:plan 文本 + 子任务列表 */
export interface DecomposeResult {
plan: string;
subtasks: Array<{ title: string; complexity: 'easy' | 'medium' | 'hard' }>;
}
export function writeJobSpec(job: JobSpec): void {
@@ -86,6 +97,8 @@ export interface ReviewReport {
* - phase :进度阶段(executing|verifying|reviewing…),daemon 落成 event 推看板(可选)
* - failed :本次尝试终态失败(executor 报错或 verify 失败)。daemon 据此 finishRun(failed)+重试决策
* - result :成功终态。daemon 据此建 reviewer/security run + setResult(四字段) + 转 exec_review
* - spec-result planner-spec 成功终态。daemon 据此 setSpec + 转 spec_review
* - decompose-resultplanner-decompose 成功终态。daemon 据此 setPlan + 建子任务 + 转 plan_review
* - done :worker 即将退出(成功或失败都发,daemon 据此停止 tail
*/
export type OutboxRecord =
@@ -99,6 +112,12 @@ export type OutboxRecord =
code: ReviewReport;
security: ReviewReport;
}
| { seq: number; at: string; type: 'spec-result'; spec: string; transcriptRef: string | null; sessionId: string | null }
| {
seq: number; at: string; type: 'decompose-result';
plan: string; subtasks: Array<{ title: string; complexity: 'easy' | 'medium' | 'hard' }>;
transcriptRef: string | null; sessionId: string | null;
}
| { seq: number; at: string; type: 'done' };
/** OutboxRecord 去掉 seq/at(由 appendOutbox 填) */
@@ -107,6 +126,8 @@ export type OutboxPayload =
| Omit<Extract<OutboxRecord, { type: 'phase' }>, 'seq' | 'at'>
| Omit<Extract<OutboxRecord, { type: 'failed' }>, 'seq' | 'at'>
| Omit<Extract<OutboxRecord, { type: 'result' }>, 'seq' | 'at'>
| Omit<Extract<OutboxRecord, { type: 'spec-result' }>, 'seq' | 'at'>
| Omit<Extract<OutboxRecord, { type: 'decompose-result' }>, 'seq' | 'at'>
| Omit<Extract<OutboxRecord, { type: 'done' }>, 'seq' | 'at'>;
/** 追加一条 outbox 记录(worker 侧调用)。seq = 现有行数+1(worker 单线程,无并发写)。返回写入的完整记录。 */
+66
View File
@@ -94,3 +94,69 @@ export async function runTask(task: Task, project: Project, worktree: WorktreeIn
}
return { ok: true, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: cc.finalText || null, modelUsed: cc.modelUsed };
}
// ───────────────────────── 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
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): string {
const head = [`# 任务:${task.title}`, `任务 ID${task.id}`];
if (kind === 'spec') {
if (task.spec) head.push('', '## 现有方案草稿(可改进/替换)', task.spec);
return [
...head, '',
'## 你的角色:方案作者(只读,不改任何文件)',
'只读这个代码库,为上述任务写一份「具体改动方案」。不要编辑文件、不要执行有副作用的命令。',
'## 输出(markdown 正文)',
'## 改动(要改哪些文件/模块、怎么改)',
'## 为什么(这么做的理由、取舍)',
'## 验收(怎么算完成、怎么验证)',
'这份方案将提交给人审;通过后才会有执行 agent 按它改代码。直接输出方案正文即可。',
].join('\n');
}
if (task.plan) head.push('', '## 现有分析草稿(可改进/替换)', task.plan);
return [
...head, '',
'## 你的角色:任务拆解者(只读,不改任何文件)',
'只读这个代码库,分析上述(复杂)任务,拆成 2–6 个更小、可独立交付的子任务。不要编辑文件、不要执行有副作用的命令。',
'每个子任务标注复杂度:easy(单文件机械改动/无设计)、medium(需方案、跨几处)、hard(仍需进一步拆解)。',
'## 输出(重要)',
'先写一段分析与拆解理由;然后在【最后】输出唯一一个 ```json 代码块,严格格式:',
'```json',
'{"plan":"一句话分析与拆解理由","subtasks":[{"title":"子任务标题","complexity":"easy"}]}',
'```',
'subtasks 至少 1 个;complexity 只能是 easy|medium|hard;除这个 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),
cwd: project.repoPath, // 只读跑在主仓(planner 不改文件,无需 worktree
model,
runId,
maxTurns: 60,
timeoutMs,
permissionMode: 'acceptEdits', // planner 只读,不会触发编辑;保持工具流不被 prompt 卡住
allowedTools: ['Read', 'Glob', 'Grep'], // 纯只读:无 Edit/Write/Bash,零副作用
});
return { ok: cc.ok, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: cc.finalText || null, error: cc.error };
}
+3 -2
View File
@@ -32,13 +32,14 @@ async function main(): Promise<void> {
const emit = (p: OutboxPayload): void => { appendOutbox(runId, p); };
const job = readJobSpec(runId);
// 起手汇报:pid / worktree / branch / 实际执行模型
// 起手汇报:pid / worktree / branch / 实际执行模型planner-* 用 planner 角色模型)
const role = job.runKind && job.runKind !== 'executor' ? 'planner' : 'executor';
emit({
type: 'started',
pid: process.pid,
worktree: job.worktreeDir,
branch: job.branch,
model: pickModel(job.task, job.project, 'executor'),
model: pickModel(job.task, job.project, role),
});
// 心跳:立即一次 + 周期刷(结束时清掉)