feat(planner): 编排器把 analyzing/speccing 也当可执行——派 planner run 自动拆解/写方案
修复"hard/medium 任务建后永远停在 analyzing/speccing"的缺口:编排器现在对 analyzing(hard) 派 planner-decompose、对 speccing(medium) 派 planner-spec(auto-approved 下;auto-easy 仍只执行 easy), planner 只读跑 CC(无 worktree/无写)产出 → daemon 落库 + 自动提交到 plan_review/spec_review 闸(仍人审)。 - protocol: JobSpec.runKind + spec-result/decompose-result 两型 outbox + DecomposeResult - runner: runPlanner(只读 Read/Glob/Grep 跑在 repo,opus 档)+ buildPlannerPrompt - pipeline: runKind 分支 + parseDecompose(取末尾 fenced JSON,非法→failed) - orchestrator: claimable 纳入 analyzing/speccing + 排除在途;并发/in-flight 从 countExecuting 泛化为 inflightTaskIds(有 started run 的任务,executor+planner 通用);claimOne 按状态分 executor/planner (planner 不转状态);reap 泛化(死 planner→failPlanAttempt) - ingest: ingestAll 覆盖所有 started run;spec-result→setSpec+spec_review;decompose-result→setPlan+建子任务+plan_review; failed 按 kind 分流(planner→failPlanAttempt 退避留态、超限→needs_attention) - store: inflightTaskIds / liveRunsWithTask / failPlanAttempt;reconcile 泛化到所有 started run - status: analyzing/speccing 加 →needs_attention(planner 失败超限升级) - models: planner 角色(opus);status: RunKind 已含 planner - 测试 +12(pipeline 5 / ingest 3 / orchestrator 4),194 全绿 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+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