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:
@@ -203,3 +203,68 @@ test('ingest 续读:先 ingest 半截(started/phase),再追加 result,
|
||||
store.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────── planner run 摄取 ─────────────────────────
|
||||
|
||||
/** 真 Store + 一个 medium(speccing) 或 hard(analyzing) 任务 + 一条 planner run(模拟已领取规划)。 */
|
||||
function setupPlanning(complexity: 'medium' | 'hard'): { store: Store; taskId: string; runId: string } {
|
||||
const store = new Store(':memory:');
|
||||
const p = store.createProject({ name: 'plan', repoPath: '/tmp/plan-' + Math.random(), autonomy: 'auto-approved' });
|
||||
const t = store.createTask({ projectId: p.id, title: '大任务', complexity });
|
||||
// 建任务即落 speccing(medium)/analyzing(hard),不转状态,直接起 planner run
|
||||
const run = store.startRun(t.id, 'planner', { worktree: p.repoPath, branch: 'n/a' });
|
||||
store.setWorkerPid(run.id, 4243);
|
||||
return { store, taskId: t.id, runId: run.id };
|
||||
}
|
||||
|
||||
test('ingest planner-spec:setSpec + 转 spec_review + 收尾 planner run', () => {
|
||||
withTmpDataDir(() => {
|
||||
const { store, taskId, runId } = setupPlanning('medium');
|
||||
assert.equal(store.getTask(taskId)!.status, 'speccing');
|
||||
appendOutbox(runId, { type: 'started', pid: 4243, worktree: '/r', branch: 'n/a', model: 'opus' });
|
||||
appendOutbox(runId, { type: 'spec-result', spec: '## 改动\n改 a.ts', transcriptRef: '/p.jsonl', sessionId: 's' });
|
||||
appendOutbox(runId, { type: 'done' });
|
||||
ingestRun(store, noopLog, runId);
|
||||
const t = store.getTask(taskId)!;
|
||||
assert.equal(t.status, 'spec_review');
|
||||
assert.equal(t.spec, '## 改动\n改 a.ts');
|
||||
assert.equal(store.getRun(runId)!.status, 'succeeded');
|
||||
store.close();
|
||||
});
|
||||
});
|
||||
|
||||
test('ingest planner-decompose:setPlan + 建子任务 + 转 plan_review', () => {
|
||||
withTmpDataDir(() => {
|
||||
const { store, taskId, runId } = setupPlanning('hard');
|
||||
assert.equal(store.getTask(taskId)!.status, 'analyzing');
|
||||
appendOutbox(runId, {
|
||||
type: 'decompose-result', plan: '拆成两步',
|
||||
subtasks: [{ title: '建骨架', complexity: 'easy' }, { title: '实现', complexity: 'medium' }],
|
||||
transcriptRef: null, sessionId: null,
|
||||
});
|
||||
appendOutbox(runId, { type: 'done' });
|
||||
ingestRun(store, noopLog, runId);
|
||||
const t = store.getTask(taskId)!;
|
||||
assert.equal(t.status, 'plan_review');
|
||||
assert.equal(t.plan, '拆成两步');
|
||||
const kids = store.listTasks(t.projectId).filter((x) => x.parentId === taskId);
|
||||
assert.equal(kids.length, 2, '建了 2 个子任务');
|
||||
assert.deepEqual(kids.map((k) => k.complexity).sort(), ['easy', 'medium']);
|
||||
assert.equal(store.getRun(runId)!.status, 'succeeded');
|
||||
store.close();
|
||||
});
|
||||
});
|
||||
|
||||
test('ingest planner failed:走 failPlanAttempt(退避,任务留 speccing,不进 failed)', () => {
|
||||
withTmpDataDir(() => {
|
||||
const { store, taskId, runId } = setupPlanning('medium');
|
||||
appendOutbox(runId, { type: 'failed', error: 'planner 崩了', transcriptRef: null, sessionId: null });
|
||||
appendOutbox(runId, { type: 'done' });
|
||||
ingestRun(store, noopLog, runId);
|
||||
const t = store.getTask(taskId)!;
|
||||
assert.equal(t.status, 'speccing', '默认 maxRetries=2,第一次失败留 speccing 退避重试');
|
||||
assert.ok(t.nextEligibleAt, '设了退避 nextEligibleAt');
|
||||
assert.equal(store.getRun(runId)!.status, 'failed');
|
||||
store.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -375,3 +375,68 @@ test(`reaper 反复回收:净失败 ${DEFAULT_MAX_RETRIES} 次后 → needs_at
|
||||
assert.equal(failed.length, DEFAULT_MAX_RETRIES + 1);
|
||||
store.close();
|
||||
});
|
||||
|
||||
// ───────────────────────── planner:analyzing / speccing 也被领取 ─────────────────────────
|
||||
|
||||
test('claimable 纳入 speccing:medium 任务派 planner-spec(不转状态、startRun=planner、job.runKind=planner-spec)', () => {
|
||||
const { store, projectId } = setup('auto-approved');
|
||||
const t = store.createTask({ projectId, title: 'feat', complexity: 'medium' }); // → speccing
|
||||
assert.equal(store.getTask(t.id)!.status, 'speccing');
|
||||
const state = freshState();
|
||||
const { deps } = mockDeps(state);
|
||||
const orch = createOrchestrator(store, noopLog, deps);
|
||||
|
||||
orch.claimTick();
|
||||
assert.equal(state.spawned.length, 1, '派了一个 planner worker');
|
||||
assert.equal(store.getTask(t.id)!.status, 'speccing', 'planner 不转任务状态(留 speccing 作规划中)');
|
||||
const runs = store.listRuns(t.id);
|
||||
assert.equal(runs.length, 1);
|
||||
assert.equal(runs[0].kind, 'planner', 'run kind=planner');
|
||||
assert.equal(runs[0].status, 'started');
|
||||
assert.equal(state.jobs[0].runKind, 'planner-spec');
|
||||
|
||||
// 已在途(有 started planner run)→ 下一轮不重领
|
||||
orch.claimTick();
|
||||
assert.equal(state.spawned.length, 1, 'inflight 任务不被重复领取');
|
||||
store.close();
|
||||
});
|
||||
|
||||
test('claimable 纳入 analyzing:hard 任务派 planner-decompose', () => {
|
||||
const { store, projectId } = setup('auto-approved');
|
||||
const t = store.createTask({ projectId, title: 'epic', complexity: 'hard' }); // → analyzing
|
||||
assert.equal(store.getTask(t.id)!.status, 'analyzing');
|
||||
const state = freshState();
|
||||
const { deps } = mockDeps(state);
|
||||
createOrchestrator(store, noopLog, deps).claimTick();
|
||||
assert.equal(state.spawned.length, 1);
|
||||
assert.equal(store.getTask(t.id)!.status, 'analyzing', 'planner 不转状态');
|
||||
assert.equal(store.listRuns(t.id)[0].kind, 'planner');
|
||||
assert.equal(state.jobs[0].runKind, 'planner-decompose');
|
||||
store.close();
|
||||
});
|
||||
|
||||
test('auto-easy 不做规划:medium(speccing) 不被领取', () => {
|
||||
const { store, projectId } = setup('auto-easy');
|
||||
const t = store.createTask({ projectId, title: 'feat', complexity: 'medium' }); // → speccing
|
||||
const state = freshState();
|
||||
const { deps } = mockDeps(state);
|
||||
createOrchestrator(store, noopLog, deps).claimTick();
|
||||
assert.equal(state.spawned.length, 0, 'auto-easy 只做 easy 执行,不规划 medium/hard');
|
||||
assert.equal(store.getTask(t.id)!.status, 'speccing');
|
||||
store.close();
|
||||
});
|
||||
|
||||
test('并发闸用 inflight:1 个 executing + 1 个 planner 占满 concurrency=2', () => {
|
||||
const { store, projectId } = setup('auto-approved', 2);
|
||||
const e = store.createTask({ projectId, title: 'easy', complexity: 'easy' }); // → ready
|
||||
const m = store.createTask({ projectId, title: 'med', complexity: 'medium' }); // → speccing
|
||||
store.createTask({ projectId, title: 'easy2', complexity: 'easy' }); // → ready(第三个)
|
||||
const state = freshState();
|
||||
const { deps } = mockDeps(state);
|
||||
createOrchestrator(store, noopLog, deps).claimTick();
|
||||
// concurrency=2:一个 executor(easy) + 一个 planner(medium) 占满,第三个不领
|
||||
assert.equal(state.spawned.length, 2);
|
||||
assert.equal(store.getTask(e.id)!.status, 'executing');
|
||||
assert.equal(store.getTask(m.id)!.status, 'speccing');
|
||||
store.close();
|
||||
});
|
||||
|
||||
+61
-1
@@ -1,6 +1,6 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { runPipeline, type PipelineDeps } from '../src/executor/pipeline.js';
|
||||
import { runPipeline, parseDecompose, type PipelineDeps } from '../src/executor/pipeline.js';
|
||||
import type { JobSpec, OutboxPayload } from '../src/executor/protocol.js';
|
||||
import type { Project, Task } from '../src/model/types.js';
|
||||
import type { RunnerResult } from '../src/executor/runner.js';
|
||||
@@ -55,6 +55,7 @@ function mockDeps(overrides: Partial<PipelineDeps> = {}): PipelineDeps {
|
||||
runTask: async () => okRun,
|
||||
reviewCode: async () => okReview,
|
||||
reviewSecurity: async () => okSecurity,
|
||||
runPlanner: async () => ({ ok: true, transcriptRef: '/tmp/plan.jsonl', sessionId: 'sess-plan', finalText: '方案正文' }),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -181,3 +182,62 @@ test('安全审计抛错不挡:security.verdict=null、summary 含「自动复
|
||||
assert.equal(result.code.verdict, 'approve');
|
||||
assert.equal(emitted.at(-1)?.type, 'done');
|
||||
});
|
||||
|
||||
// ───────────────────────── planner 分支 ─────────────────────────
|
||||
|
||||
const medJob = (over: Partial<JobSpec> = {}): JobSpec => ({ ...fakeJob, runKind: 'planner-spec', task: { ...fakeTask, complexity: 'medium', status: 'speccing' }, ...over });
|
||||
const hardJob = (over: Partial<JobSpec> = {}): JobSpec => ({ ...fakeJob, runKind: 'planner-decompose', task: { ...fakeTask, complexity: 'hard', status: 'analyzing' }, ...over });
|
||||
|
||||
test('planner-spec:emit spec-result(方案=CC 文本)+ done,不复审/不建 worktree', async () => {
|
||||
const { emitted, emit } = collector();
|
||||
let worktreeCalled = false;
|
||||
await runPipeline(medJob(), mockDeps({
|
||||
createWorktree: async () => { worktreeCalled = true; return { dir: 'x', branch: 'y' }; },
|
||||
runPlanner: async () => ({ ok: true, transcriptRef: '/tmp/p.jsonl', sessionId: 's', finalText: '## 改动\n改 a.ts' }),
|
||||
}), emit);
|
||||
assert.equal(worktreeCalled, false, 'planner 不建 worktree');
|
||||
const sr = find(emitted, 'spec-result');
|
||||
assert.ok(sr, '应 emit spec-result');
|
||||
assert.equal(sr.spec, '## 改动\n改 a.ts');
|
||||
assert.equal(find(emitted, 'result'), undefined);
|
||||
assert.equal(find(emitted, 'failed'), undefined);
|
||||
assert.equal(emitted.at(-1)?.type, 'done');
|
||||
});
|
||||
|
||||
test('planner-decompose:解析末尾 fenced JSON → emit decompose-result + done', async () => {
|
||||
const { emitted, emit } = collector();
|
||||
const txt = '分析:拆成两步。\n```json\n{"plan":"先骨架再实现","subtasks":[{"title":"建骨架","complexity":"easy"},{"title":"实现逻辑","complexity":"medium"}]}\n```';
|
||||
await runPipeline(hardJob(), mockDeps({ runPlanner: async () => ({ ok: true, transcriptRef: null, sessionId: null, finalText: txt }) }), emit);
|
||||
const dr = find(emitted, 'decompose-result');
|
||||
assert.ok(dr, '应 emit decompose-result');
|
||||
assert.equal(dr.plan, '先骨架再实现');
|
||||
assert.equal(dr.subtasks.length, 2);
|
||||
assert.deepEqual(dr.subtasks.map((s) => s.complexity), ['easy', 'medium']);
|
||||
assert.equal(emitted.at(-1)?.type, 'done');
|
||||
});
|
||||
|
||||
test('planner-decompose:输出无合法 JSON → emit failed', async () => {
|
||||
const { emitted, emit } = collector();
|
||||
await runPipeline(hardJob(), mockDeps({ runPlanner: async () => ({ ok: true, transcriptRef: null, sessionId: null, finalText: '我忘了输出 JSON' }) }), emit);
|
||||
assert.ok(find(emitted, 'failed'), '解析失败应 emit failed');
|
||||
assert.equal(find(emitted, 'decompose-result'), undefined);
|
||||
assert.equal(emitted.at(-1)?.type, 'done');
|
||||
});
|
||||
|
||||
test('planner:CC 失败 → emit failed', async () => {
|
||||
const { emitted, emit } = collector();
|
||||
await runPipeline(medJob(), mockDeps({ runPlanner: async () => ({ ok: false, transcriptRef: null, sessionId: null, finalText: null, error: 'CC 崩了' }) }), emit);
|
||||
const f = find(emitted, 'failed');
|
||||
assert.ok(f);
|
||||
assert.match(f.error, /CC 崩了/);
|
||||
assert.equal(emitted.at(-1)?.type, 'done');
|
||||
});
|
||||
|
||||
test('parseDecompose:取最后一个 json 块;非法/缺失 → null', () => {
|
||||
assert.equal(parseDecompose('no json here'), null);
|
||||
assert.equal(parseDecompose('```json\n{"subtasks":[]}\n```'), null, '空 subtasks → null');
|
||||
const ok = parseDecompose('前文\n```json\n{"subtasks":[{"title":"a","complexity":"hard"},{"title":"","complexity":"easy"},{"title":"b","complexity":"x"}]}\n```');
|
||||
assert.ok(ok);
|
||||
assert.equal(ok.subtasks.length, 1, '过滤空标题与非法复杂度');
|
||||
assert.equal(ok.subtasks[0].title, 'a');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user