b1d881a4b4
不新增顶层状态,用 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>
1055 lines
52 KiB
TypeScript
1055 lines
52 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 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/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 {
|
||
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.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 ?? [];
|
||
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→ready(reconcile 内部广播 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.updated(payload 带判定档与理由),便于看板/用户复核。
|
||
*/
|
||
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 但依赖未满足 → 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,可接受)。
|
||
*/
|
||
/**
|
||
* 失败/重试策略的唯一落点(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 run(reaper / 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(保留 executing,daemon 续 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(改进意见)。
|
||
* 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)!;
|
||
}
|
||
|
||
/**
|
||
* 手动一键重投 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);
|
||
}
|
||
|
||
/** 所有进行中的 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);
|
||
}
|
||
|
||
/** 单条 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;
|
||
}
|
||
}
|