feat: Phase2 完整管线——score 调度 + 自动复审 + 模型分级 + 归档与详情

调度:
- model/scoring.ts: score = 自身分(P0=3/P1=2/P2=1) + 已完成依赖分(链条惯性)
  + 等待解锁的 blocked 任务分(解锁加权),编排器与 nextExecutable 同一打分
- createTask 校验 deps 存在且同项目(依赖图天然无环)
- daemon 重启中断自愈: executing 任务标 failed run 后重新入队(reconcileInterrupted)

执行管线:
- executor/cc.ts: 公共 headless CC 执行器(转录/超时/模型回退重试)
- executor/reviewer.ts: 执行后自动复审(只读 CC 审 diff),固定模板 summary
  (做了什么/怎么做/测试/CodeReview/安全Review/结论) + VERDICT 解析
- executor/models.ts: 按复杂度选模型(easy→sonnet/medium→opus/hard→fable5),
  env 可覆盖、project.model 最优先、不可用自动回退链
- runner: 测试/构建命令白名单(npm/go/shellcheck/make/pytest),prompt 要求实跑测试
- TaskResult 加 summary/verdict; RunKind 加 reviewer
- 容器收口: 已拆解 Hard 子任务全 done → 容器自动 done(afterDone 逐级向上)

看板:
- 五徽章组(待审批/待执行/执行中/被阻塞/总量,hover 展开,均不含已完成)
- 归档区: 深度1整树完成沉底,时间倒序分页(10/20/50/100 chip 选择)
- 归档详情对话框: 全属性/执行历史与时长/审批记录/状态流转时间线(含相关人或事)
- Agent 面板显示调度模式 + 各复杂度实际模型
- 结果闸展示复审 summary + 建议通过/拒绝徽章
- 筛选修复(组选与单选分离、已拆解移出进行中)、同步按钮收进配置面板、
  保存配置自动收起、预览全宽、被依赖阻塞→被阻塞
- API: GET /api/tasks/:id/events(任务级事件时间线)、/api/agents 带 scheduling/models

测试: 49/49(新增 scoring/复审/模型/容器收口/deps 校验/中断恢复)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-13 02:46:37 +08:00
parent fa472a06a7
commit f18db021c3
25 changed files with 2215 additions and 56 deletions
+64 -15
View File
@@ -7,6 +7,7 @@ import {
import type { Project, Task, ApprovalRecord, Run, Event, TaskResult, Autonomy, EventType } from '../model/types.js';
import { DEFAULT_MAX_DEPTH, HARD_MAX_DEPTH } from '../model/types.js';
import type { Complexity } from '../model/complexity.js';
import { rankByScore } from '../model/scoring.js';
import {
type TaskStatus, type GateKind, canTransition, initialNextStatus, gateOf, STATUS_LABEL,
} from '../model/status.js';
@@ -174,9 +175,15 @@ export class Store {
}
if (input.priority !== undefined) assertPriority(input.priority);
// deps 必须引用本项目已存在的任务(也因此依赖图天然无环:新任务不可能已被引用)
const depsArr = input.deps ?? [];
for (const d of depsArr) {
const dep = this.getTaskRow(d);
if (!dep) throw new StoreError(`依赖任务不存在: ${d}`);
if (dep.project_id !== input.projectId) throw new StoreError(`依赖任务不在同一项目: ${d}`);
}
let status = initialNextStatus(input.complexity);
// Easy 直达 ready,但有未完成依赖时落位 blocked
const depsArr = input.deps ?? [];
if (status === 'ready' && depsArr.length && !depsArr.every((d) => this.getTaskRow(d)?.status === 'done')) {
status = 'blocked';
}
@@ -333,6 +340,26 @@ export class Store {
}
}
/**
* 任务变 done 后的连锁处理:
* 1. 放行依赖它的 blocked 任务;
* 2. 已拆解容器的子任务全部 done/取消 → 容器自动 done(“深度1整树完成”生命周期闭环),逐级向上。
*/
private afterDone(projectId: string, taskId: string): void {
this.releaseDependents(projectId);
let cur = this.getTaskRow(taskId);
while (cur?.parent_id) {
const parent = this.getTaskRow(cur.parent_id);
if (!parent || parent.status !== 'decomposed') break;
const kids = this.db.prepare(`SELECT status FROM tasks WHERE parent_id = ?`).all(parent.id) as Array<{ status: string }>;
if (!kids.every((k) => k.status === 'done' || k.status === 'cancelled')) break;
this.db.prepare(`UPDATE tasks SET status = 'done', updated_at = ? WHERE id = ?`).run(now(), parent.id);
this.emit(parent.project_id, parent.id, 'status.changed', { from: 'decomposed', to: 'done', auto: 'children-done' });
this.releaseDependents(projectId); // 容器本身可能是别人的依赖
cur = parent;
}
}
/**
* 全量依赖对账(幂等):ready 但依赖未满足 → blockedblocked 但依赖已满足 → ready。
* daemon 启动时调用,纠正存量数据。
@@ -361,6 +388,22 @@ export class Store {
return { blocked: nBlocked, released: nReleased };
}
/**
* daemon 启动时的中断现场恢复:上次进程退出时仍 started 的 run 标 failed
* 卡在 executing 的任务转 failed→queued 等编排器重新领取。
* 注:中断产生的 failed run 会计入该任务的失败次数(多次中断+真失败可能提前转 needs_attention,可接受)。
*/
reconcileInterrupted(): { runs: number; tasks: number } {
const runs = this.db.prepare(`SELECT * FROM runs WHERE status = 'started'`).all() as RunRow[];
for (const r of runs) this.finishRun(r.id, 'failed', { error: 'daemon 重启,执行中断' });
const rows = this.db.prepare(`SELECT * FROM tasks WHERE status = 'executing'`).all() as TaskRow[];
for (const t of rows) {
this.transition(t.id, 'failed', { auto: 'daemon-restart' });
this.transition(t.id, 'queued', { auto: 'daemon-restart' });
}
return { runs: runs.length, tasks: rows.length };
}
/**
* 仅供导入器:旧系统中已完成的任务直接置 done(绕过执行链路与依赖落位——
* 历史事实优先,避免已完成的工作被当作待执行复活),并放行依赖它的任务。
@@ -372,7 +415,7 @@ export class Store {
if (from === 'done') return this.getTask(taskId)!;
this.db.prepare(`UPDATE tasks SET status = 'done', updated_at = ? WHERE id = ?`).run(now(), taskId);
this.emit(row.project_id, taskId, 'status.changed', { from, to: 'done', auto: 'import-done', actor });
this.releaseDependents(row.project_id);
this.afterDone(row.project_id, taskId);
return this.getTask(taskId)!;
}
@@ -392,7 +435,7 @@ export class Store {
this.emit(row.project_id, taskId, 'status.changed', {
from, to: actual, ...(actual !== to ? { requested: to, auto: 'deps-unmet' } : {}), ...meta,
});
if (actual === 'done') this.releaseDependents(row.project_id);
if (actual === 'done') this.afterDone(row.project_id, taskId);
return this.getTask(taskId)!;
}
@@ -444,7 +487,7 @@ export class Store {
});
txn();
this.emit(row.project_id, taskId, action === 'accept' ? 'approval.granted' : 'approval.rejected', { gate, from, to, reason: reason ?? null });
if (to === 'done') this.releaseDependents(row.project_id); // 完成 → 自动放行依赖它的任务
if (to === 'done') this.afterDone(row.project_id, taskId); // 完成 → 放行依赖 + 容器自动收口
return this.getTask(taskId)!;
}
@@ -494,6 +537,14 @@ export class Store {
}
// ---------- Events ----------
/** 单任务全量事件(升序):状态流转/审批/运行历史,供归档详情时间线 */
listTaskEvents(taskId: string): Event[] {
const rows = this.db.prepare(
`SELECT * FROM events WHERE task_id = ? ORDER BY at`,
).all(taskId) as EventRow[];
return rows.map(rowToEvent);
}
listEvents(projectId: string, limit = 200): Event[] {
const rows = this.db.prepare(
`SELECT * FROM events WHERE project_id = ? ORDER BY at DESC LIMIT ?`,
@@ -506,16 +557,14 @@ export class Store {
* 被拆解的 Hard 容器任务不会是 ready(停在 decomposed),天然排除。
*/
nextExecutable(projectId: string): Task | null {
const rows = this.db.prepare(
`SELECT * FROM tasks WHERE project_id = ? AND status = 'ready' ORDER BY priority ASC, created_at`,
).all(projectId) as TaskRow[];
for (const r of rows) {
if (this.childrenOf(r.id).length > 0) continue; // 非叶子跳过
const deps = JSON.parse(r.deps) as string[];
const depsDone = deps.every((d) => this.getTaskRow(d)?.status === 'done');
if (!depsDone) continue;
return rowToTask(r, this.listApprovals(r.id));
}
return null;
const all = this.listTasks(projectId);
const byId = new Map(all.map((t) => [t.id, t]));
const parents = new Set(all.filter((t) => t.parentId).map((t) => t.parentId as string));
const candidates = all.filter((t) =>
t.status === 'ready' && !parents.has(t.id) &&
t.deps.every((d) => byId.get(d)?.status === 'done'),
);
const ranked = rankByScore(candidates, all); // 与编排器同一打分(见 model/scoring.ts
return ranked[0]?.task ?? null;
}
}