f18db021c3
调度: - 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>
571 lines
27 KiB
TypeScript
571 lines
27 KiB
TypeScript
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/2(P0 最高,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.synced(payload=同步统计)。 */
|
||
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 但依赖未满足 → blocked;blocked 但依赖已满足 → 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(改进意见)。
|
||
* accept:plan_review→decomposed, spec_review→ready, exec_review→done。
|
||
* reject:plan_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);
|
||
}
|
||
|
||
/** 所有进行中的 run(status='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;
|
||
}
|
||
}
|