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:
wangjia
2026-06-13 16:59:30 +08:00
parent dcf610f6fd
commit 8392944877
12 changed files with 501 additions and 46 deletions
+45 -10
View File
@@ -26,7 +26,7 @@ export function ingestRun(store: Store, log: IngestLogger, runId: string): void
const recs = readOutboxSince(runId, run.lastSeq);
for (const rec of recs) {
try {
applyRecord(store, log, taskId, runId, rec);
applyRecord(store, log, taskId, runId, run.kind, rec);
} catch (e) {
// 单条映射失败不阻断后续 run 的摄取;但本条不推进游标,下轮重试。
log.error(`ingest run=${runId} seq=${rec.seq} 失败:${(e as Error).message}`);
@@ -36,8 +36,8 @@ export function ingestRun(store: Store, log: IngestLogger, runId: string): void
}
}
/** 把一条 OutboxRecord 映射为 DB 写。 */
function applyRecord(store: Store, log: IngestLogger, taskId: string, runId: string, rec: OutboxRecord): void {
/** 把一条 OutboxRecord 映射为 DB 写。runKind 用于区分 failed 的收尾策略(executor vs planner)。 */
function applyRecord(store: Store, log: IngestLogger, taskId: string, runId: string, runKind: string, rec: OutboxRecord): void {
switch (rec.type) {
case 'started':
// worker 启动自报;pid 已由 daemon spawn 时 setWorkerPid 写过,这里仅记日志。
@@ -50,8 +50,8 @@ function applyRecord(store: Store, log: IngestLogger, taskId: string, runId: str
return;
case 'failed': {
// 先把 executor run 收尾为 failed(带转录/会话),再走失败/重试策略。
// failTaskAttempt 见 run 已 ended(非 started)不会重复收尾,只做重试决策。
// 先把 run 收尾为 failed(带转录/会话),再走失败/重试策略(按 kind 分流)
// failTaskAttempt/failPlanAttempt 见 run 已 ended(非 started)不会重复收尾,只做重试决策。
try {
store.finishRun(runId, 'failed', {
error: rec.error,
@@ -61,8 +61,44 @@ function applyRecord(store: Store, log: IngestLogger, taskId: string, runId: str
} catch (e) {
log.error(`task=${taskId} run=${runId} 收尾 failed 出错(继续重试决策):${(e as Error).message}`);
}
store.failTaskAttempt(taskId, runId, rec.error);
log.info(`task=${taskId} run=${runId} 执行失败:${rec.error}`);
if (runKind === 'planner') store.failPlanAttempt(taskId, runId, rec.error); // planner:退避留 analyzing/speccing
else store.failTaskAttempt(taskId, runId, rec.error); // executorfailed→重试/needs_attention
log.info(`task=${taskId} run=${runId}(${runKind}) 失败:${rec.error}`);
return;
}
case 'spec-result': {
// planner-spec 成功:写方案 → 转 spec_review(待你审)→ 收尾 planner run。
store.setSpec(taskId, rec.spec);
store.transition(taskId, 'spec_review', { by: 'ingest', runId });
store.finishRun(runId, 'succeeded', {
transcriptRef: rec.transcriptRef ?? undefined,
claudeSessionId: rec.sessionId ?? undefined,
});
log.info(`task=${taskId} run=${runId} 方案完成 → spec_review`);
return;
}
case 'decompose-result': {
// planner-decompose 成功:写分析 + 建子任务 + 转 plan_review(待你审)→ 收尾 planner run。
const task = store.getTask(taskId);
if (!task) { log.error(`decompose: 任务不存在 ${taskId}`); return; }
store.setPlan(taskId, rec.plan || '(无分析说明)');
let created = 0;
for (const sub of rec.subtasks) {
try {
store.createTask({ projectId: task.projectId, parentId: taskId, title: sub.title, complexity: sub.complexity });
created++;
} catch (e) {
log.error(`decompose: 建子任务「${sub.title}」失败:${(e as Error).message}`);
}
}
store.transition(taskId, 'plan_review', { by: 'ingest', runId, subtasks: created });
store.finishRun(runId, 'succeeded', {
transcriptRef: rec.transcriptRef ?? undefined,
claudeSessionId: rec.sessionId ?? undefined,
});
log.info(`task=${taskId} run=${runId} 拆解完成 → plan_review${created} 个子任务)`);
return;
}
@@ -103,12 +139,11 @@ function applyRecord(store: Store, log: IngestLogger, taskId: string, runId: str
}
/**
* 摄取所有在途 run:对每个 executing 任务的最近一条 executor run 调 ingestRun。
* 摄取所有在途 runexecutor + planner):遍历所有 started run 调 ingestRun。
* daemon 每轮 tick 调一次,把 worker 期间累积的 outbox 落库。
*/
export function ingestAll(store: Store, log: IngestLogger): void {
for (const { run } of store.executingWithLatestExecutorRun()) {
if (!run) continue;
for (const { run } of store.liveRunsWithTask()) {
ingestRun(store, log, run.id);
}
}