Files
maestro/src/store/store.ts
T
wangjia f18db021c3 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>
2026-06-13 02:46:37 +08:00

571 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { nanoid } from 'nanoid';
import { openDb, type DB } from './db.js';
import {
rowToProject, rowToTask, rowToApproval, rowToRun, rowToEvent,
type ProjectRow, type TaskRow, type ApprovalRow, type RunRow, type EventRow,
} from './mappers.js';
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';
const now = (): string => new Date().toISOString();
const id = (prefix: string): string => `${prefix}_${nanoid(12)}`;
export class StoreError extends Error {}
export interface CreateProjectInput {
name: string; repoPath: string; defaultBranch?: string; verifyCmd?: string | null;
autonomy?: Autonomy; model?: string | null; concurrency?: number;
}
export interface CreateTaskInput {
projectId: string; title: string; complexity: Complexity;
parentId?: string | null; priority?: number; deps?: string[];
}
export interface PatchProjectInput {
autonomy?: Autonomy; concurrency?: number; verifyCmd?: string | null;
model?: string | null; status?: 'active' | 'paused';
}
export interface PatchTaskInput {
title?: string; priority?: number; complexity?: Complexity;
}
/** 执行中的 run(联 tasks 取标题/项目),供 GET /api/agents 汇总 */
export interface ActiveRun {
runId: string; taskId: string; taskTitle: string; kind: string;
startedAt: string; projectId: string;
}
const AUTONOMY_VALUES: readonly Autonomy[] = ['manual', 'auto-easy', 'auto-approved'];
/** 优先级取值:P0 最高 / P1 中(默认)/ P2 最低 */
function assertPriority(p: number): void {
if (!Number.isInteger(p) || p < 0 || p > 2) {
throw new StoreError('priority 必须是 0/1/2P0 最高,P1 中,P2 最低)');
}
}
/** 允许修改 complexity 的状态:尚未进入执行/收尾链路 */
const COMPLEXITY_EDITABLE: ReadonlySet<TaskStatus> = new Set<TaskStatus>([
'init', 'analyzing', 'speccing', 'ready', 'plan_review', 'spec_review', 'blocked',
]);
type EventListener = (e: Event) => void;
/**
* 任务系统的唯一数据出入口。所有状态变更都经 transition() 守卫(canTransition),
* 审批 reject 强制带改进意见,每次变更追加 Event 并广播给监听者(供 WS 推送)。
*/
export class Store {
readonly db: DB;
private listeners = new Set<EventListener>();
constructor(file: string) {
this.db = openDb(file);
}
close(): void { this.db.close(); }
/** 订阅事件流(看板 WS)。返回取消订阅函数。 */
subscribe(fn: EventListener): () => void {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
private emit(projectId: string, taskId: string | null, type: EventType, payload: Record<string, unknown> = {}): Event {
const row: EventRow = {
id: id('evt'), project_id: projectId, task_id: taskId, type,
payload: JSON.stringify(payload), at: now(),
};
this.db.prepare(
`INSERT INTO events (id, project_id, task_id, type, payload, at) VALUES (@id,@project_id,@task_id,@type,@payload,@at)`,
).run(row);
const evt = rowToEvent(row);
for (const l of this.listeners) { try { l(evt); } catch { /* listener 错误不影响主流程 */ } }
return evt;
}
// ---------- Projects ----------
createProject(input: CreateProjectInput): Project {
const row: ProjectRow = {
id: id('prj'), name: input.name, repo_path: input.repoPath,
default_branch: input.defaultBranch ?? 'main', verify_cmd: input.verifyCmd ?? null,
autonomy: input.autonomy ?? 'manual', model: input.model ?? null,
concurrency: input.concurrency ?? 1, status: 'active', created_at: now(),
last_sync_at: null,
};
this.db.prepare(
`INSERT INTO projects (id,name,repo_path,default_branch,verify_cmd,autonomy,model,concurrency,status,created_at,last_sync_at)
VALUES (@id,@name,@repo_path,@default_branch,@verify_cmd,@autonomy,@model,@concurrency,@status,@created_at,@last_sync_at)`,
).run(row);
this.emit(row.id, null, 'task.created', { kind: 'project', name: row.name });
return rowToProject(row);
}
listProjects(): Project[] {
const rows = this.db.prepare(`SELECT * FROM projects ORDER BY created_at`).all() as ProjectRow[];
return rows.map(rowToProject);
}
getProject(projectId: string): Project | null {
const row = this.db.prepare(`SELECT * FROM projects WHERE id = ?`).get(projectId) as ProjectRow | undefined;
return row ? rowToProject(row) : null;
}
/** 部分更新项目配置(autonomy/concurrency/verifyCmd/model/status),带合法性校验。 */
patchProject(projectId: string, patch: PatchProjectInput): Project {
const cur = this.getProject(projectId);
if (!cur) throw new StoreError(`项目不存在: ${projectId}`);
const sets: string[] = [];
const args: Record<string, unknown> = { id: projectId };
if (patch.autonomy !== undefined) {
if (!AUTONOMY_VALUES.includes(patch.autonomy)) {
throw new StoreError('autonomy 必须是 manual | auto-easy | auto-approved');
}
sets.push('autonomy = @autonomy'); args.autonomy = patch.autonomy;
}
if (patch.concurrency !== undefined) {
if (!Number.isInteger(patch.concurrency) || patch.concurrency < 1) {
throw new StoreError('concurrency 必须是 >=1 的整数');
}
sets.push('concurrency = @concurrency'); args.concurrency = patch.concurrency;
}
if (patch.status !== undefined) {
if (patch.status !== 'active' && patch.status !== 'paused') {
throw new StoreError('status 必须是 active | paused');
}
sets.push('status = @status'); args.status = patch.status;
}
if (patch.verifyCmd !== undefined) { sets.push('verify_cmd = @verify_cmd'); args.verify_cmd = patch.verifyCmd; }
if (patch.model !== undefined) { sets.push('model = @model'); args.model = patch.model; }
if (sets.length > 0) {
this.db.prepare(`UPDATE projects SET ${sets.join(', ')} WHERE id = @id`).run(args);
this.emit(projectId, null, 'task.updated', { kind: 'project', fields: Object.keys(patch) });
}
return this.getProject(projectId)!;
}
/** 标记项目完成一次 todo.json 同步:写 last_sync_at 并广播 project.syncedpayload=同步统计)。 */
markSynced(projectId: string, payload: Record<string, unknown> = {}): string {
const cur = this.getProject(projectId);
if (!cur) throw new StoreError(`项目不存在: ${projectId}`);
const at = now();
this.db.prepare(`UPDATE projects SET last_sync_at = ? WHERE id = ?`).run(at, projectId);
this.emit(projectId, null, 'project.synced', { ...payload, lastSyncAt: at });
return at;
}
// ---------- Tasks ----------
createTask(input: CreateTaskInput): Task {
const project = this.getProject(input.projectId);
if (!project) throw new StoreError(`项目不存在: ${input.projectId}`);
let depth = 1;
if (input.parentId) {
const parent = this.getTaskRow(input.parentId);
if (!parent) throw new StoreError(`父任务不存在: ${input.parentId}`);
if (parent.project_id !== input.projectId) throw new StoreError('子任务必须与父任务同项目');
depth = parent.depth + 1;
const max = parent.complexity === 'hard' ? HARD_MAX_DEPTH : DEFAULT_MAX_DEPTH;
if (depth > max) throw new StoreError(`层级超限:最多 ${max} 层(父任务 ${parent.complexity}`);
}
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
if (status === 'ready' && depsArr.length && !depsArr.every((d) => this.getTaskRow(d)?.status === 'done')) {
status = 'blocked';
}
const row: TaskRow = {
id: id('tsk'), project_id: input.projectId, parent_id: input.parentId ?? null, depth,
title: input.title, complexity: input.complexity, status, priority: input.priority ?? 1,
deps: JSON.stringify(input.deps ?? []), plan: null, spec: null, operations: null,
result: null, assignee: null, created_at: now(), updated_at: now(),
source_ref: null,
};
this.db.prepare(
`INSERT INTO tasks (id,project_id,parent_id,depth,title,complexity,status,priority,deps,plan,spec,operations,result,assignee,created_at,updated_at,source_ref)
VALUES (@id,@project_id,@parent_id,@depth,@title,@complexity,@status,@priority,@deps,@plan,@spec,@operations,@result,@assignee,@created_at,@updated_at,@source_ref)`,
).run(row);
this.emit(input.projectId, row.id, 'task.created', { title: row.title, complexity: row.complexity, status });
return rowToTask(row);
}
private getTaskRow(taskId: string): TaskRow | undefined {
return this.db.prepare(`SELECT * FROM tasks WHERE id = ?`).get(taskId) as TaskRow | undefined;
}
getTask(taskId: string): Task | null {
const row = this.getTaskRow(taskId);
if (!row) return null;
return rowToTask(row, this.listApprovals(taskId));
}
listTasks(projectId: string): Task[] {
const rows = this.db.prepare(
`SELECT * FROM tasks WHERE project_id = ? ORDER BY depth, priority ASC, created_at`,
).all(projectId) as TaskRow[];
return rows.map((r) => rowToTask(r, this.listApprovals(r.id)));
}
childrenOf(taskId: string): Task[] {
const rows = this.db.prepare(
`SELECT * FROM tasks WHERE parent_id = ? ORDER BY priority ASC, created_at`,
).all(taskId) as TaskRow[];
return rows.map((r) => rowToTask(r, this.listApprovals(r.id)));
}
/** 写产出:Hard→plan / Medium→spec / Easy→operations。仅更新对应字段。 */
setPlan(taskId: string, plan: string): Task { return this.patchField(taskId, 'plan', plan); }
setSpec(taskId: string, spec: string): Task { return this.patchField(taskId, 'spec', spec); }
setOperations(taskId: string, ops: string): Task { return this.patchField(taskId, 'operations', ops); }
private patchField(taskId: string, field: 'plan' | 'spec' | 'operations', value: string): Task {
const row = this.getTaskRow(taskId);
if (!row) throw new StoreError(`任务不存在: ${taskId}`);
this.db.prepare(`UPDATE tasks SET ${field} = ?, updated_at = ? WHERE id = ?`).run(value, now(), taskId);
this.emit(row.project_id, taskId, 'task.updated', { field });
return this.getTask(taskId)!;
}
setResult(taskId: string, result: TaskResult): Task {
const row = this.getTaskRow(taskId);
if (!row) throw new StoreError(`任务不存在: ${taskId}`);
this.db.prepare(`UPDATE tasks SET result = ?, updated_at = ? WHERE id = ?`).run(JSON.stringify(result), now(), taskId);
this.emit(row.project_id, taskId, 'task.updated', { field: 'result' });
return this.getTask(taskId)!;
}
/**
* 部分更新任务(title/priority/complexity)。
* complexity 修改仅允许尚未进入执行链路的状态(init/analyzing/speccing/ready/plan_review/spec_review/blocked);
* 改后 status 重置为新复杂度的初始态(initialNextStatus)并广播 status.changed。
*/
patchTask(taskId: string, patch: PatchTaskInput): Task {
const row = this.getTaskRow(taskId);
if (!row) throw new StoreError(`任务不存在: ${taskId}`);
const fields: string[] = [];
if (patch.title !== undefined) {
if (!patch.title.trim()) throw new StoreError('title 不能为空');
this.db.prepare(`UPDATE tasks SET title = ?, updated_at = ? WHERE id = ?`).run(patch.title, now(), taskId);
fields.push('title');
}
if (patch.priority !== undefined) {
assertPriority(patch.priority);
this.db.prepare(`UPDATE tasks SET priority = ?, updated_at = ? WHERE id = ?`).run(patch.priority, now(), taskId);
fields.push('priority');
}
let statusChange: { from: TaskStatus; to: TaskStatus } | null = null;
if (patch.complexity !== undefined && patch.complexity !== row.complexity) {
const cur = row.status as TaskStatus;
if (!COMPLEXITY_EDITABLE.has(cur)) {
throw new StoreError(
`当前状态 ${STATUS_LABEL[cur]}(${cur}) 不允许修改复杂度(仅限 init/analyzing/speccing/ready/plan_review/spec_review/blocked`,
);
}
const to = this.resolveReady(row, initialNextStatus(patch.complexity)); // ready 落位时考虑依赖
this.db.prepare(`UPDATE tasks SET complexity = ?, status = ?, updated_at = ? WHERE id = ?`)
.run(patch.complexity, to, now(), taskId);
fields.push('complexity');
if (to !== cur) statusChange = { from: cur, to };
}
if (fields.length > 0) {
this.emit(row.project_id, taskId, 'task.updated', { fields });
if (statusChange) {
this.emit(row.project_id, taskId, 'status.changed', { ...statusChange, reason: 'complexity.changed' });
}
}
return this.getTask(taskId)!;
}
// ---------- 旧 todo 来源映射(sync 引擎用) ----------
setSourceRef(taskId: string, ref: string): void {
const row = this.getTaskRow(taskId);
if (!row) throw new StoreError(`任务不存在: ${taskId}`);
this.db.prepare(`UPDATE tasks SET source_ref = ? WHERE id = ?`).run(ref, taskId);
}
getTaskBySourceRef(projectId: string, ref: string): Task | null {
const row = this.db.prepare(
`SELECT * FROM tasks WHERE project_id = ? AND source_ref = ?`,
).get(projectId, ref) as TaskRow | undefined;
return row ? rowToTask(row, this.listApprovals(row.id)) : null;
}
/** 项目内全部已映射任务:source_ref → Task */
sourceRefMap(projectId: string): Map<string, Task> {
const rows = this.db.prepare(
`SELECT * FROM tasks WHERE project_id = ? AND source_ref IS NOT NULL`,
).all(projectId) as TaskRow[];
const map = new Map<string, Task>();
for (const r of rows) map.set(r.source_ref!, rowToTask(r));
return map;
}
// ---------- 依赖驱动的 ready/blocked 自动管理 ----------
/** 依赖是否全部 done(未知 id 视为未满足) */
private depsMetRow(row: TaskRow): boolean {
const deps = JSON.parse(row.deps) as string[];
return deps.every((d) => this.getTaskRow(d)?.status === 'done');
}
/** 意图是 ready 时按依赖落位:未满足 → blocked(系统自动管理,不可手动绕过) */
private resolveReady(row: TaskRow, to: TaskStatus): TaskStatus {
return to === 'ready' && !this.depsMetRow(row) ? 'blocked' : to;
}
/** 某任务 done 后:把同项目内依赖已全部满足的 blocked 任务自动放行为 ready */
private releaseDependents(projectId: string): void {
const rows = this.db.prepare(
`SELECT * FROM tasks WHERE project_id = ? AND status = 'blocked'`,
).all(projectId) as TaskRow[];
for (const r of rows) {
if (!this.depsMetRow(r)) continue;
this.db.prepare(`UPDATE tasks SET status = 'ready', updated_at = ? WHERE id = ?`).run(now(), r.id);
this.emit(projectId, r.id, 'status.changed', { from: 'blocked', to: 'ready', auto: 'deps-met' });
}
}
/**
* 任务变 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 启动时调用,纠正存量数据。
*/
reconcileDeps(projectId?: string): { blocked: number; released: number } {
const pids = projectId ? [projectId] : this.listProjects().map((p) => p.id);
let nBlocked = 0;
let nReleased = 0;
for (const pid of pids) {
const rows = this.db.prepare(
`SELECT * FROM tasks WHERE project_id = ? AND status IN ('ready','blocked')`,
).all(pid) as TaskRow[];
for (const r of rows) {
const met = this.depsMetRow(r);
if (r.status === 'ready' && !met) {
this.db.prepare(`UPDATE tasks SET status = 'blocked', updated_at = ? WHERE id = ?`).run(now(), r.id);
this.emit(pid, r.id, 'status.changed', { from: 'ready', to: 'blocked', auto: 'deps-reconcile' });
nBlocked++;
} else if (r.status === 'blocked' && met) {
this.db.prepare(`UPDATE tasks SET status = 'ready', updated_at = ? WHERE id = ?`).run(now(), r.id);
this.emit(pid, r.id, 'status.changed', { from: 'blocked', to: 'ready', auto: 'deps-reconcile' });
nReleased++;
}
}
}
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(绕过执行链路与依赖落位——
* 历史事实优先,避免已完成的工作被当作待执行复活),并放行依赖它的任务。
*/
forceDone(taskId: string, actor: string): Task {
const row = this.getTaskRow(taskId);
if (!row) throw new StoreError(`任务不存在: ${taskId}`);
const from = row.status as TaskStatus;
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.afterDone(row.project_id, taskId);
return this.getTask(taskId)!;
}
/** 受守卫的状态变更:非法流转抛错;记录 status.changed 事件。 */
transition(taskId: string, to: TaskStatus, meta: Record<string, unknown> = {}): Task {
const row = this.getTaskRow(taskId);
if (!row) throw new StoreError(`任务不存在: ${taskId}`);
const from = row.status as TaskStatus;
if (from === to) return this.getTask(taskId)!;
if (!canTransition(from, to)) {
throw new StoreError(`非法状态流转:${STATUS_LABEL[from]}(${from}) → ${STATUS_LABEL[to]}(${to})`);
}
// 意图 ready 但依赖未满足 → 系统落位 blocked
const actual = this.resolveReady(row, to);
if (from === actual) return this.getTask(taskId)!;
this.db.prepare(`UPDATE tasks SET status = ?, updated_at = ? WHERE id = ?`).run(actual, now(), taskId);
this.emit(row.project_id, taskId, 'status.changed', {
from, to: actual, ...(actual !== to ? { requested: to, auto: 'deps-unmet' } : {}), ...meta,
});
if (actual === 'done') this.afterDone(row.project_id, taskId);
return this.getTask(taskId)!;
}
// ---------- Approvals(审批闸) ----------
private listApprovals(taskId: string): ApprovalRecord[] {
const rows = this.db.prepare(`SELECT * FROM approvals WHERE task_id = ? ORDER BY at`).all(taskId) as ApprovalRow[];
return rows.map(rowToApproval);
}
/** 列出所有处于审批闸状态(plan_review/spec_review/exec_review)的任务。 */
pendingApprovals(projectId?: string): Task[] {
const sql = projectId
? `SELECT * FROM tasks WHERE project_id = ? AND status IN ('plan_review','spec_review','exec_review') ORDER BY updated_at`
: `SELECT * FROM tasks WHERE status IN ('plan_review','spec_review','exec_review') ORDER BY updated_at`;
const rows = (projectId
? this.db.prepare(sql).all(projectId)
: this.db.prepare(sql).all()) as TaskRow[];
return rows.map((r) => rowToTask(r, this.listApprovals(r.id)));
}
/**
* 处理一次审批。accept/reject 必须发生在闸状态上;reject 必须带 reason(改进意见)。
* acceptplan_review→decomposed, spec_review→ready, exec_review→done。
* rejectplan_review→analyzing, spec_review→speccing, exec_review→ready(返工)。
*/
decide(taskId: string, action: 'accept' | 'reject', actor: string, reason?: string | null): Task {
const row = this.getTaskRow(taskId);
if (!row) throw new StoreError(`任务不存在: ${taskId}`);
const from = row.status as TaskStatus;
const gate: GateKind | null = gateOf(from);
if (!gate) throw new StoreError(`任务不在审批闸状态:当前 ${STATUS_LABEL[from]}(${from})`);
if (action === 'reject' && !reason?.trim()) {
throw new StoreError('reject 必须填写改进意见');
}
const intended: TaskStatus = action === 'accept'
? ({ plan: 'decomposed', spec: 'ready', exec: 'done' } as const)[gate]
: ({ plan: 'analyzing', spec: 'speccing', exec: 'ready' } as const)[gate];
const to = this.resolveReady(row, intended); // spec accept / exec reject → ready 时按依赖落位
const txn = this.db.transaction(() => {
const ap: ApprovalRow = {
id: id('apr'), task_id: taskId, gate, action, actor, reason: reason ?? null, at: now(),
};
this.db.prepare(
`INSERT INTO approvals (id,task_id,gate,action,actor,reason,at) VALUES (@id,@task_id,@gate,@action,@actor,@reason,@at)`,
).run(ap);
this.db.prepare(`UPDATE tasks SET status = ?, updated_at = ? WHERE id = ?`).run(to, now(), taskId);
});
txn();
this.emit(row.project_id, taskId, action === 'accept' ? 'approval.granted' : 'approval.rejected', { gate, from, to, reason: reason ?? null });
if (to === 'done') this.afterDone(row.project_id, taskId); // 完成 → 放行依赖 + 容器自动收口
return this.getTask(taskId)!;
}
// ---------- Runs ----------
startRun(taskId: string, kind: Run['kind'], fields: Partial<Pick<Run, 'worktree' | 'branch'>> = {}): Run {
const row = this.getTaskRow(taskId);
if (!row) throw new StoreError(`任务不存在: ${taskId}`);
const rr: RunRow = {
id: id('run'), task_id: taskId, kind, worktree: fields.worktree ?? null, branch: fields.branch ?? null,
status: 'started', started_at: now(), ended_at: null, transcript_ref: null, claude_session_id: null, error: null,
};
this.db.prepare(
`INSERT INTO runs (id,task_id,kind,worktree,branch,status,started_at,ended_at,transcript_ref,claude_session_id,error)
VALUES (@id,@task_id,@kind,@worktree,@branch,@status,@started_at,@ended_at,@transcript_ref,@claude_session_id,@error)`,
).run(rr);
this.emit(row.project_id, taskId, 'run.started', { runId: rr.id, kind });
return rowToRun(rr);
}
finishRun(runId: string, status: Run['status'], fields: Partial<Pick<Run, 'transcriptRef' | 'claudeSessionId' | 'error'>> = {}): Run {
const row = this.db.prepare(`SELECT * FROM runs WHERE id = ?`).get(runId) as RunRow | undefined;
if (!row) throw new StoreError(`run 不存在: ${runId}`);
this.db.prepare(
`UPDATE runs SET status = ?, ended_at = ?, transcript_ref = ?, claude_session_id = ?, error = ? WHERE id = ?`,
).run(status, now(), fields.transcriptRef ?? row.transcript_ref, fields.claudeSessionId ?? row.claude_session_id, fields.error ?? row.error, runId);
const task = this.getTaskRow(row.task_id);
if (task) this.emit(task.project_id, row.task_id, 'run.finished', { runId, status });
return rowToRun(this.db.prepare(`SELECT * FROM runs WHERE id = ?`).get(runId) as RunRow);
}
/** 所有进行中的 runstatus='started'),联 tasks 取任务标题与项目。 */
activeRuns(): ActiveRun[] {
const rows = this.db.prepare(
`SELECT r.id AS run_id, r.task_id, r.kind, r.started_at, t.title, t.project_id
FROM runs r JOIN tasks t ON t.id = r.task_id
WHERE r.status = 'started' ORDER BY r.started_at`,
).all() as Array<{ run_id: string; task_id: string; kind: string; started_at: string; title: string; project_id: string }>;
return rows.map((r) => ({
runId: r.run_id, taskId: r.task_id, taskTitle: r.title,
kind: r.kind, startedAt: r.started_at, projectId: r.project_id,
}));
}
listRuns(taskId: string): Run[] {
const rows = this.db.prepare(`SELECT * FROM runs WHERE task_id = ? ORDER BY started_at`).all(taskId) as RunRow[];
return rows.map(rowToRun);
}
// ---------- 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 ?`,
).all(projectId, limit) as EventRow[];
return rows.map(rowToEvent).reverse();
}
/**
* 取下一个可执行任务(叶子、ready、依赖全部 done)。供编排器领取。
* 被拆解的 Hard 容器任务不会是 ready(停在 decomposed),天然排除。
*/
nextExecutable(projectId: string): Task | 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;
}
}