Files
maestro/src/store/store.ts
T
wangjia b1d881a4b4 feat: 合并失败→补救→完成 全程事件轨迹与自动收口 [tsk_BagzbKZNAHXV]
不新增顶层状态,用 events + result 字段记录完整轨迹:
- 新增 merge.failed / merge.remediated 两类事件
- merge 失败时在原任务时间线记 merge.failed 节点(结构化冲突文件 + 补救任务链接)
- 补救任务成功合并后,findMergeOriginTask 反查原任务,remediateOrigin 自动把原任务标 done
  并记 merge.remediated 节点,无需人工 merge:false
- 看板:归档时间线渲染失败/补救节点;结果评审闸显示「合并失败 → 补救任务」横幅

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 15:04:39 +08:00

1055 lines
52 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 Metrics, type ModelUsage, percentile, ratio, estimateCostUnits, emptyMetrics,
} from '../model/metrics.js';
import { resolvedExecutorModels } from '../executor/models.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;
maxRetries?: number; timeoutMs?: 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'; logo?: string | null;
maxRetries?: number; timeoutMs?: number;
}
export interface PatchTaskInput {
title?: string; priority?: number; complexity?: Complexity; deps?: string[];
}
/** 执行中的 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 {
if (input.maxRetries !== undefined && (!Number.isInteger(input.maxRetries) || input.maxRetries < 0)) {
throw new StoreError('maxRetries 必须是 >=0 的整数');
}
if (input.timeoutMs !== undefined && (!Number.isFinite(input.timeoutMs) || input.timeoutMs < 1000)) {
throw new StoreError('timeoutMs 必须是 >=1000 的数字(毫秒)');
}
const maxOrder = (this.db.prepare(`SELECT COALESCE(MAX(sort_order), -1) AS m FROM projects`).get() as { m: number }).m;
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, max_retries: input.maxRetries ?? 2,
timeout_ms: input.timeoutMs ?? 1_800_000,
status: 'active', created_at: now(),
last_sync_at: null, logo: null, sort_order: maxOrder + 1,
};
this.db.prepare(
`INSERT INTO projects (id,name,repo_path,default_branch,verify_cmd,autonomy,model,concurrency,max_retries,timeout_ms,status,created_at,last_sync_at,logo,sort_order)
VALUES (@id,@name,@repo_path,@default_branch,@verify_cmd,@autonomy,@model,@concurrency,@max_retries,@timeout_ms,@status,@created_at,@last_sync_at,@logo,@sort_order)`,
).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 sort_order, created_at`).all() as ProjectRow[];
return rows.map(rowToProject);
}
/** 重排项目(侧栏拖动):按给定 id 顺序写 sort_order;未列出的排在后面、相对顺序不变。 */
reorderProjects(orderedIds: string[]): Project[] {
const txn = this.db.transaction(() => {
let i = 0;
const upd = this.db.prepare(`UPDATE projects SET sort_order = ? WHERE id = ?`);
for (const pid of orderedIds) upd.run(i++, pid);
// 未列出的项目顺延到末尾(保持原相对序)
const rest = this.db.prepare(
`SELECT id FROM projects WHERE id NOT IN (${orderedIds.map(() => '?').join(',') || 'NULL'}) ORDER BY sort_order, created_at`,
).all(...orderedIds) as Array<{ id: string }>;
for (const r of rest) upd.run(i++, r.id);
});
txn();
return this.listProjects();
}
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;
}
/**
* 彻底删除项目(不可恢复):tasks→runs/approvals 经 schema 的 ON DELETE CASCADE 连带清(foreign_keys=ON),
* events 表无外键、手动按 project_id 删。事务内一次性完成。
*/
deleteProject(projectId: string): void {
if (!this.getProject(projectId)) throw new StoreError(`项目不存在: ${projectId}`);
this.db.transaction(() => {
this.db.prepare(`DELETE FROM events WHERE project_id = ?`).run(projectId);
this.db.prepare(`DELETE FROM projects WHERE id = ?`).run(projectId);
})();
}
/** 部分更新项目配置(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 (patch.logo !== undefined) { sets.push('logo = @logo'); args.logo = patch.logo; }
if (patch.maxRetries !== undefined) {
if (!Number.isInteger(patch.maxRetries) || patch.maxRetries < 0) {
throw new StoreError('maxRetries 必须是 >=0 的整数');
}
sets.push('max_retries = @max_retries'); args.max_retries = patch.maxRetries;
}
if (patch.timeoutMs !== undefined) {
if (!Number.isFinite(patch.timeoutMs) || patch.timeoutMs < 1000) {
throw new StoreError('timeoutMs 必须是 >=1000 的数字(毫秒)');
}
sets.push('timeout_ms = @timeout_ms'); args.timeout_ms = patch.timeoutMs;
}
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 ?? [];
this.assertDepsRefer(input.projectId, depsArr);
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, retry_baseline: 0, next_eligible_at: 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,retry_baseline,created_at,updated_at,source_ref)
VALUES (@id,@project_id,@parent_id,@depth,@title,@complexity,@status,@priority,@deps,@plan,@spec,@operations,@result,@assignee,@retry_baseline,@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)!;
}
/**
* 自动合并失败时,为原任务建一个最高优先级(P0)补救任务来完成合并。
* 幂等:原任务 result.mergeTaskId 已指向一个未结束(非 done/cancelled)的任务则直接复用,不重复建。
* 补救任务为 easy(直达 ready,自治项目会自动领取去解冲突),operations 写明分支/目标/冲突详情与步骤。
*/
ensureMergeRemediationTask(
taskId: string,
opts: { branch: string; targetBranch: string; conflictError: string; conflictFiles?: string[] },
): Task {
const orig = this.getTask(taskId);
if (!orig) throw new StoreError(`任务不存在: ${taskId}`);
const existingId = orig.result?.mergeTaskId;
if (existingId) {
const existing = this.getTask(existingId);
if (existing && existing.status !== 'done' && existing.status !== 'cancelled') {
// 复用既有补救任务:仍记一次「合并失败」节点(每次失败的合并尝试都是真实事件)
this.emit(orig.projectId, orig.id, 'merge.failed', {
remediationTaskId: existing.id, branch: opts.branch, targetBranch: opts.targetBranch,
conflictFiles: opts.conflictFiles ?? [], error: opts.conflictError, reused: true,
});
return existing;
}
}
const rem = this.createTask({
projectId: orig.projectId,
title: `合并冲突待解决:${orig.title}`,
complexity: 'easy',
priority: 0, // 最高优先级
});
this.setOperations(
rem.id,
`## 操作(自动生成 · 合并冲突补救)\n` +
`原任务「${orig.title}」(${orig.id}) 的分支 \`${opts.branch}\` 自动合并到 \`${opts.targetBranch}\` 时发生冲突,需解决冲突并完成合并。\n\n` +
`### 冲突详情\n${opts.conflictError}\n\n` +
`### 步骤(在本任务 worktree 内,已从 ${opts.targetBranch} 检出)\n` +
`1. \`git merge ${opts.branch}\` 触发冲突;\n` +
`2. 逐个解决冲突文件,保留两边意图(不要丢任一方改动);\n` +
`3. \`git add -A && git commit\`(沿用默认合并信息即可);\n` +
`4. 本任务进 exec_review 后,「通过并合并」会把已解决的结果干净并入 ${opts.targetBranch}\n` +
`5. 完成后,原任务 ${orig.id} 可用「仅通过」标记 done(其改动已随本任务并入)。`,
);
// 幂等标记写回原任务 result(保留原有结果字段)
if (orig.result) this.setResult(orig.id, { ...orig.result, mergeTaskId: rem.id });
this.emit(orig.projectId, rem.id, 'task.updated', { field: 'merge-remediation', forTask: orig.id });
// 在原任务时间线上记一个「合并失败 → 补救任务」节点
this.emit(orig.projectId, orig.id, 'merge.failed', {
remediationTaskId: rem.id, branch: opts.branch, targetBranch: opts.targetBranch,
conflictFiles: opts.conflictFiles ?? [], error: opts.conflictError,
});
return this.getTask(rem.id)!;
}
/**
* 反查某补救任务对应的原任务(其 result.mergeTaskId 指向该补救任务)。
* 用于补救任务成功合并后自动收口原任务。无 → null。
*/
findMergeOriginTask(remediationTaskId: string): Task | null {
const row = this.db.prepare(
`SELECT * FROM tasks WHERE json_extract(result, '$.mergeTaskId') = ? LIMIT 1`,
).get(remediationTaskId) as TaskRow | undefined;
return row ? this.getTask(row.id)! : null;
}
/**
* 补救完成自动收口:补救任务成功合并到默认分支后,把原任务标 done(原任务改动已随补救任务并入),
* 在原任务时间线追加 merge.remediated 事件「补救完成 → 完成」,无需人工对原任务 merge:false。
* 原任务须仍在 exec_review(合并闸);否则不动(已被人工处理 / 已结束)。
*/
remediateOrigin(originId: string, remediationTaskId: string, mergeCommit?: string): Task | null {
const row = this.getTaskRow(originId);
if (!row) return null;
if (row.status !== 'exec_review') return this.getTask(originId)!; // 已被人工处理 / 非合并闸 → 不动
// 合并产物记录:复用 prUrl 字段标记「经补救任务并入」
const cur = this.getTask(originId)!;
if (cur.result) {
this.setResult(originId, { ...cur.result, prUrl: `merged-via:${remediationTaskId}${mergeCommit ? ':' + mergeCommit : ''}` });
}
this.emit(row.project_id, originId, 'merge.remediated', { remediationTaskId, mergeCommit: mergeCommit ?? null });
// exec_review → done(合法流转):触发 afterDone 放行依赖 / 容器收口
this.transition(originId, 'done', { by: 'merge-remediation', auto: 'merge-remediated', remediationTaskId });
return this.getTask(originId)!;
}
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/deps)。
* complexity / deps 修改仅允许尚未进入执行链路的状态(init/analyzing/speccing/ready/plan_review/spec_review/blocked);
* 改 complexity 后 status 重置为新复杂度的初始态(initialNextStatus);
* 改 deps 后复用建任务的校验(存在/同项目/无环),再 reconcileDeps 重算 ready↔blocked,均广播 status.changed。
*/
patchTask(taskId: string, patch: PatchTaskInput): Task {
let row = this.getTaskRow(taskId);
if (!row) throw new StoreError(`任务不存在: ${taskId}`);
const fields: string[] = [];
let depsChanged = false;
if (patch.deps !== undefined) {
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`,
);
}
this.assertDepsRefer(row.project_id, patch.deps, taskId); // 存在 + 同项目 + 非自身
this.assertNoDepCycle(taskId, patch.deps); // DFS 环检测
this.db.prepare(`UPDATE tasks SET deps = ?, updated_at = ? WHERE id = ?`)
.run(JSON.stringify(patch.deps), now(), taskId);
row = this.getTaskRow(taskId)!; // 刷新,供后续 complexity 落位看到新 deps
fields.push('deps');
depsChanged = true;
}
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' });
}
}
// deps 变更后重算依赖落位:未满足的 ready→blocked、刚补全的 blocked→readyreconcile 内部广播 status.changed
if (depsChanged) this.reconcileDeps(row.project_id);
return this.getTask(taskId)!;
}
/**
* AUTO 复杂度回填:模型判定后设置任务复杂度并记录判定理由(建任务时占位 medium,此处异步回填)。
* - 判定档与占位不同且仍可改(COMPLEXITY_EDITABLE)→ 改复杂度 + 重置状态(同 patchTask 落位逻辑,含依赖);
* - 理由写入该复杂度对应产出字段(hard→plan / medium→spec / easy→operations)的备注,仅在该字段为空时写,避免覆盖;
* - 始终广播 task.updatedpayload 带判定档与理由),便于看板/用户复核。
*/
applyAutoComplexity(taskId: string, complexity: Complexity, reason: string): Task {
const row = this.getTaskRow(taskId);
if (!row) throw new StoreError(`任务不存在: ${taskId}`);
const cur = row.status as TaskStatus;
let statusChange: { from: TaskStatus; to: TaskStatus } | null = null;
if (complexity !== row.complexity && COMPLEXITY_EDITABLE.has(cur)) {
const to = this.resolveReady(row, initialNextStatus(complexity));
this.db.prepare(`UPDATE tasks SET complexity = ?, status = ?, updated_at = ? WHERE id = ?`)
.run(complexity, to, now(), taskId);
if (to !== cur) statusChange = { from: cur, to };
}
// 理由写入对应产出字段(仅当为空),便于用户与执行 agent 复核
const field = complexity === 'hard' ? 'plan' : complexity === 'medium' ? 'spec' : 'operations';
const fresh = this.getTaskRow(taskId)!;
if (!fresh[field]) {
const note = `> AUTO·模型判定复杂度:${complexity}\n> 理由:${reason}`;
this.db.prepare(`UPDATE tasks SET ${field} = ?, updated_at = ? WHERE id = ?`).run(note, now(), taskId);
}
this.emit(row.project_id, taskId, 'task.updated', { auto: 'classify', complexity, reason });
if (statusChange) {
this.emit(row.project_id, taskId, 'status.changed', { ...statusChange, reason: 'complexity.auto-classified' });
}
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;
}
// ---------- 依赖校验 ----------
/** 每个 dep 必须存在、同项目、且不等于任务自身(建/改任务时复用) */
private assertDepsRefer(projectId: string, deps: string[], selfId?: string): void {
for (const d of deps) {
if (selfId && d === selfId) throw new StoreError('任务不能依赖自己');
const dep = this.getTaskRow(d);
if (!dep) throw new StoreError(`依赖任务不存在: ${d}`);
if (dep.project_id !== projectId) throw new StoreError(`依赖任务不在同一项目: ${d}`);
}
}
/**
* 环检测(DFS):把 taskId 的依赖改为 deps 后,沿现有依赖图从任一 dep 出发若能回到 taskId
* 即构成环(A→B→A),拒绝。仅在编辑既有任务的 deps 时需要(新建任务天然无环)。
*/
private assertNoDepCycle(taskId: string, deps: string[]): void {
const reaches = (from: string): boolean => {
const seen = new Set<string>();
const dfs = (cur: string): boolean => {
if (cur === taskId) return true; // 回到自身 → 成环
if (seen.has(cur)) return false;
seen.add(cur);
const r = this.getTaskRow(cur);
if (!r) return false;
return (JSON.parse(r.deps) as string[]).some(dfs);
};
return dfs(from);
};
for (const d of deps) {
if (reaches(d)) throw new StoreError(`依赖会形成环: ${taskId}${d}`);
}
}
// ---------- 依赖驱动的 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,可接受)。
*/
/**
* 失败/重试策略的唯一落点(ingest 的 failed 记录、reaper 判死、reconcile 判死 都调它)。
* 把(可选)执行 run 收尾为 failed,按「净失败次数 vs project.maxRetries」决定:
* 未到上限 → queued + 持久化退避(next_eligible_at = now + 指数退避)
* 到上限 → needs_attention(清退避)
* netFailed 相对 retry_baseline(手动重投时设的基线)。退避:min(30s·2^(n-1), 10min)。
*/
failTaskAttempt(taskId: string, runId: string | null, error: string): Task {
const row = this.getTaskRow(taskId);
if (!row) throw new StoreError(`任务不存在: ${taskId}`);
const project = this.getProject(row.project_id);
if (!project) throw new StoreError(`项目不存在: ${row.project_id}`);
// 收尾执行 run:给了 runId 且仍 started → 标 failed;没给 → 补记一条 failed(保证重试计数不漏)
if (runId) {
const rr = this.db.prepare(`SELECT status FROM runs WHERE id = ?`).get(runId) as { status: string } | undefined;
if (rr && rr.status === 'started') this.finishRun(runId, 'failed', { error });
} else {
const r = this.startRun(taskId, 'executor');
this.finishRun(r.id, 'failed', { error });
}
if ((this.getTaskRow(taskId)!.status as TaskStatus) !== 'failed') {
this.transition(taskId, 'failed', { by: 'failTaskAttempt', error });
}
const allFailed = (this.db.prepare(
`SELECT COUNT(*) AS n FROM runs WHERE task_id = ? AND kind = 'executor' AND status = 'failed'`,
).get(taskId) as { n: number }).n;
const netFailed = Math.max(0, allFailed - row.retry_baseline);
const priorNetFailed = Math.max(0, netFailed - 1); // 不含本次
if (priorNetFailed < project.maxRetries) {
const attempt = priorNetFailed + 1;
const backoffMs = Math.min(30_000 * 2 ** (attempt - 1), 10 * 60_000);
this.setNextEligibleAt(taskId, new Date(Date.now() + backoffMs).toISOString());
this.transition(taskId, 'queued', { by: 'failTaskAttempt', retry: attempt, backoffMs });
} else {
this.setNextEligibleAt(taskId, null);
this.transition(taskId, 'needs_attention', { by: 'failTaskAttempt', allFailed, netFailed });
}
return this.getTask(taskId)!;
}
/** 持久化退避:早于 next_eligible_at 不被领取(null=即刻可领)。 */
setNextEligibleAt(taskId: string, iso: string | null): void {
this.db.prepare(`UPDATE tasks SET next_eligible_at = ?, updated_at = ? WHERE id = ?`).run(iso, now(), taskId);
}
/** 项目内 executing 任务数(多进程执行的并发闸:每个 executing 对应一个活 worker)。 */
countExecuting(projectId: string): number {
return (this.db.prepare(
`SELECT COUNT(*) AS n FROM tasks WHERE project_id = ? AND status = 'executing'`,
).get(projectId) as { n: number }).n;
}
/** 所有 executing 任务 + 其最近一条 executor runreaper / reconcile 判活用)。run 可能为 null(异常)。 */
executingWithLatestExecutorRun(): Array<{ task: Task; run: Run | null }> {
const rows = this.db.prepare(`SELECT * FROM tasks WHERE status = 'executing'`).all() as TaskRow[];
return rows.map((t) => {
const rr = this.db.prepare(
`SELECT * FROM runs WHERE task_id = ? AND kind = 'executor' ORDER BY started_at DESC LIMIT 1`,
).get(t.id) as RunRow | undefined;
return { task: rowToTask(t), run: rr ? rowToRun(rr) : null };
});
}
/**
* 启动中断恢复(多进程执行):逐个 executing 任务按注入的 isAlive 判活——
* 活 → re-adopt(保留 executingdaemon 续 ingest 其 outbox);
* 死 → failTaskAttempt(收尾 + 重试/needs_attention)。
* isAlive 由 daemon 注入(protocol.isWorkerAlive,基于 worker_pid + 心跳);默认保守判死(无判活信息时回收)。
*/
reconcileInterrupted(isAlive: (run: Run | null) => boolean = () => false): { readopted: number; reclaimed: number } {
let readopted = 0, reclaimed = 0;
for (const { task, run } of this.executingWithLatestExecutorRun()) {
if (run && isAlive(run)) { readopted++; continue; }
this.failTaskAttempt(task.id, run?.id ?? null, 'daemon 重启时发现 worker 已退出');
reclaimed++;
}
return { readopted, reclaimed };
}
/**
* 仅供导入器:旧系统中已完成的任务直接置 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)!;
}
/**
* 手动一键重投 needs_attention 任务:
* 1. 记录当前失败 run 数为新基线(重置重试计数);
* 2. 转 needs_attention → queued,让编排器下一轮重新领取。
*/
requeueTask(taskId: string): Task {
const row = this.getTaskRow(taskId);
if (!row) throw new StoreError(`任务不存在: ${taskId}`);
if (row.status !== 'needs_attention') {
throw new StoreError(`只有 needs_attention 状态的任务才可重投(当前状态:${row.status}`);
}
const failedCount = (this.db.prepare(
`SELECT COUNT(*) AS n FROM runs WHERE task_id = ? AND kind = 'executor' AND status = 'failed'`,
).get(taskId) as { n: number }).n;
this.db.prepare(`UPDATE tasks SET retry_baseline = ?, updated_at = ? WHERE id = ?`).run(failedCount, now(), taskId);
this.transition(taskId, 'queued', { by: 'user', action: 'requeue' });
this.emit(row.project_id, taskId, 'task.updated', { field: 'retryBaseline', retryBaseline: failedCount });
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,
worker_pid: null, last_seq: 0,
};
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 的 worker 进程 pid(多进程执行,daemon spawn 后写;判活用)。 */
setWorkerPid(runId: string, pid: number): void {
this.db.prepare(`UPDATE runs SET worker_pid = ? WHERE id = ?`).run(pid, runId);
}
/** 更新已 ingest 的 outbox 最大 seq(幂等游标;daemon ingest 后写,重启续读)。 */
setLastSeq(runId: string, seq: number): void {
this.db.prepare(`UPDATE runs SET last_seq = ? WHERE id = ?`).run(seq, runId);
}
/** 所有进行中的 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);
}
/** 单条 run(转录查看器校验 runId 合法性用);不存在 → undefined */
getRun(runId: string): Run | undefined {
const row = this.db.prepare(`SELECT * FROM runs WHERE id = ?`).get(runId) as RunRow | undefined;
return row ? rowToRun(row) : undefined;
}
// ---------- 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();
}
/**
* 彻底删除任务及其全部子孙(runs/approvals 由 FK CASCADE 自动清除,events 手动清理)。
* 不可恢复——调用方须在上层做二次确认。
*/
deleteTask(taskId: string): { deleted: number } {
const root = this.getTaskRow(taskId);
if (!root) throw new StoreError(`任务不存在: ${taskId}`);
// BFS 收集所有子孙 id(用于清理 events,其他表有 FK ON DELETE CASCADE
const toDelete: string[] = [];
const queue: string[] = [taskId];
while (queue.length) {
const tid = queue.shift()!;
toDelete.push(tid);
const kids = this.db.prepare(
`SELECT id FROM tasks WHERE parent_id = ?`,
).all(tid) as Array<{ id: string }>;
for (const k of kids) queue.push(k.id);
}
const projectId = root.project_id;
const txn = this.db.transaction(() => {
// events 没有 FK,逐条清理
for (const tid of toDelete) {
this.db.prepare(`DELETE FROM events WHERE task_id = ?`).run(tid);
}
// 删根节点:子孙、runs、approvals 均 ON DELETE CASCADE
this.db.prepare(`DELETE FROM tasks WHERE id = ?`).run(taskId);
});
txn();
this.emit(projectId, null, 'task.updated', {
kind: 'delete', taskId, count: toDelete.length,
});
return { deleted: toDelete.length };
}
/**
* 聚合指标(健康度 + 成本):从 runs / tasks / events 算执行时长、成功率、复审通过率、
* 重试率与按模型额度估算。projectId 省略 = 全部项目。空数据时各项归零/为 null,不崩。
*
* - 执行时长:仅计入 executor run 中已结束(ended_at 非空)且 ended≥started 的样本,
* 避免未结束 run 与时区/时钟问题产生负值。
* - 成功率:executor run 中 succeeded/(succeeded+failed)started/cancelled 不计。
* - 复审通过率:任务 result 里 verdict / securityVerdict = approve 的占比。
* - 重试率:status.changed 事件中 failed→queued 次数 / 任务数;并计当前 needs_attention 数。
* - 额度:无 token 用量记录,按 执行分钟数 × 模型档位权重 粗估(estimated=true),按模型汇总。
*/
metrics(projectId?: string): Metrics {
// 范围内项目(用于把 run 解析到实际执行模型)。指定项目却不存在 → 空指标。
const projects = projectId
? (this.getProject(projectId) ? [this.getProject(projectId)!] : [])
: this.listProjects();
if (projectId && projects.length === 0) return emptyMetrics(projectId);
const modelByProject = new Map<string, Record<Complexity, string>>();
for (const p of projects) modelByProject.set(p.id, resolvedExecutorModels(p));
// ---- executor runs(联 tasks 取 complexity / project,用于时长 + 成功率 + 模型归集)----
const runSql =
`SELECT r.started_at AS started, r.ended_at AS ended, r.status AS status,
t.complexity AS complexity, t.project_id AS project_id
FROM runs r JOIN tasks t ON t.id = r.task_id
WHERE r.kind = 'executor'${projectId ? ' AND t.project_id = @pid' : ''}`;
const runStmt = this.db.prepare(runSql);
const runRows = (projectId ? runStmt.all({ pid: projectId }) : runStmt.all()) as Array<{
started: string; ended: string | null; status: string; complexity: string; project_id: string;
}>;
const durations: number[] = [];
const modelAgg = new Map<string, { runs: number; durationMs: number }>();
let exSucceeded = 0;
let exFailed = 0;
for (const r of runRows) {
if (r.status === 'succeeded') exSucceeded++;
else if (r.status === 'failed') exFailed++;
if (!r.ended) continue; // 未结束 → 不计时长
const ms = Date.parse(r.ended) - Date.parse(r.started);
if (!Number.isFinite(ms) || ms < 0) continue; // 畸形/负值(时区/时钟)→ 跳过
durations.push(ms);
const resolved = modelByProject.get(r.project_id);
const model = resolved?.[r.complexity as Complexity] ?? 'unknown';
const agg = modelAgg.get(model) ?? { runs: 0, durationMs: 0 };
agg.runs++; agg.durationMs += ms;
modelAgg.set(model, agg);
}
durations.sort((a, b) => a - b);
const durSum = durations.reduce((a, b) => a + b, 0);
const duration = {
count: durations.length,
avgMs: durations.length ? Math.round(durSum / durations.length) : 0,
p50Ms: percentile(durations, 50),
p95Ms: percentile(durations, 95),
maxMs: durations.length ? durations[durations.length - 1] : 0,
};
const exTotal = exSucceeded + exFailed;
const runs = {
total: exTotal, succeeded: exSucceeded, failed: exFailed,
successRate: ratio(exSucceeded, exTotal),
};
const byModel: ModelUsage[] = [...modelAgg.entries()]
.map(([model, a]) => ({
model, runs: a.runs, durationMs: a.durationMs,
estCostUnits: estimateCostUnits(a.durationMs, model), estimated: true,
}))
.sort((x, y) => y.estCostUnits - x.estCostUnits || y.durationMs - x.durationMs);
// ---- tasks(复审通过率 + needs_attention + 任务数)----
const taskRows = (projectId
? this.db.prepare(`SELECT result, status FROM tasks WHERE project_id = ?`).all(projectId)
: this.db.prepare(`SELECT result, status FROM tasks`).all()) as Array<{ result: string | null; status: string }>;
let needsAttention = 0;
let reviewTotal = 0; let reviewApprove = 0;
let securityTotal = 0; let securityApprove = 0;
for (const t of taskRows) {
if (t.status === 'needs_attention') needsAttention++;
if (!t.result) continue;
let res: Partial<TaskResult>;
try { res = JSON.parse(t.result) as Partial<TaskResult>; } catch { continue; }
if (res.verdict === 'approve' || res.verdict === 'reject') {
reviewTotal++; if (res.verdict === 'approve') reviewApprove++;
}
if (res.securityVerdict === 'approve' || res.securityVerdict === 'reject') {
securityTotal++; if (res.securityVerdict === 'approve') securityApprove++;
}
}
const taskCount = taskRows.length;
const review = {
reviewTotal, reviewApprove, reviewRate: ratio(reviewApprove, reviewTotal),
securityTotal, securityApprove, securityRate: ratio(securityApprove, securityTotal),
};
// ---- events(重试次数:status.changed 中 failed→queued----
const evRows = (projectId
? this.db.prepare(`SELECT payload FROM events WHERE project_id = ? AND type = 'status.changed'`).all(projectId)
: this.db.prepare(`SELECT payload FROM events WHERE type = 'status.changed'`).all()) as Array<{ payload: string }>;
let retries = 0;
for (const e of evRows) {
try {
const p = JSON.parse(e.payload) as { from?: string; to?: string };
if (p.from === 'failed' && p.to === 'queued') retries++;
} catch { /* 畸形 payload 跳过 */ }
}
const retry = {
retries, taskCount, retryRate: ratio(retries, taskCount), needsAttention,
};
return {
projectId: projectId ?? null,
taskCount, duration, runs, review, retry, byModel, estimated: true,
};
}
/**
* 取下一个可执行任务(叶子、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;
}
}