merge: maestro/tsk_lDx6zd-EbA00 [指标统计:执行时长 / 成功率 / 额度消耗估算]

# Conflicts:
#	web/style.css
This commit is contained in:
wangjia
2026-06-13 12:01:18 +08:00
8 changed files with 582 additions and 2 deletions
+7
View File
@@ -155,6 +155,13 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
return store.nextExecutable(id) ?? { next: null };
});
// ---------- Metrics(健康度 + 成本聚合)----------
// GET /api/metrics?project=<id>;不带 project = 全部项目聚合。
app.get('/api/metrics', (req) => {
const q = req.query as { project?: string };
return store.metrics(q.project && q.project.trim() ? q.project.trim() : undefined);
});
// ---------- Tasks ----------
app.post('/api/projects/:id/tasks', (req) => {
const { id } = req.params as { id: string };
+110
View File
@@ -0,0 +1,110 @@
/**
* 指标聚合的类型与纯函数(无 I/O,便于单测)。
* 实际从 runs/events/tasks 聚合的逻辑见 Store.metrics()。
*/
/** executor 执行时长统计(仅计入已结束、时长非负的 run;单位毫秒) */
export interface DurationStats {
count: number; // 计入统计的 run 数
avgMs: number; // 平均时长
p50Ms: number; // 中位数
p95Ms: number; // 95 分位
maxMs: number; // 最长
}
/** executor 运行成功率(只统计已落定的 succeeded/failedstarted/cancelled 不计) */
export interface RunStats {
total: number; // succeeded + failed
succeeded: number;
failed: number;
successRate: number | null; // succeeded / total;无样本 → null
}
/** 复审通过率(code review + 安全审计;verdict=approve 占比) */
export interface ReviewStats {
reviewTotal: number; // 有 review verdictapprove/reject)的任务数
reviewApprove: number;
reviewRate: number | null; // approve / total;无样本 → null
securityTotal: number; // 有 security verdict 的任务数
securityApprove: number;
securityRate: number | null;
}
/** 重试 / 需人工关注 */
export interface RetryStats {
retries: number; // failed→queued 重试发生次数(来自 status.changed 事件)
taskCount: number; // 范围内任务总数(重试率分母)
retryRate: number | null; // retries / taskCount;无任务 → null
needsAttention: number; // 当前处于 needs_attention 的任务数
}
/** 按模型的额度估算(无 token 数据时按 时长×档位 粗估,estimated=true */
export interface ModelUsage {
model: string;
runs: number; // 计入时长的 executor run 数
durationMs: number; // 累计执行时长
estCostUnits: number; // 估算额度(相对单位:执行分钟数 × 档位权重)
estimated: boolean; // 是否为估算(当前恒 true
}
export interface Metrics {
projectId: string | null; // null = 全部项目
taskCount: number;
duration: DurationStats;
runs: RunStats;
review: ReviewStats;
retry: RetryStats;
byModel: ModelUsage[]; // 按估算额度降序
estimated: boolean; // 额度是否为估算(无 token 用量 → true
}
/**
* 各模型档位的“每分钟相对额度权重”(粗估用,非真实计费):
* fable-5hard)最贵 → opusmedium)次之 → sonneteasy)最省。
* 未知模型用 DEFAULT_COST_PER_MIN 兜底。
*/
export const MODEL_COST_PER_MIN: Record<string, number> = {
'claude-fable-5': 1.0,
'claude-opus-4-8': 0.6,
'claude-sonnet-4-6': 0.2,
};
export const DEFAULT_COST_PER_MIN = 0.4;
export function costPerMin(model: string): number {
return MODEL_COST_PER_MIN[model] ?? DEFAULT_COST_PER_MIN;
}
/** 最近秩法分位数(sortedAsc 必须升序);空数组 → 0。 */
export function percentile(sortedAsc: number[], p: number): number {
if (sortedAsc.length === 0) return 0;
const rank = Math.ceil((p / 100) * sortedAsc.length);
const idx = Math.min(sortedAsc.length - 1, Math.max(0, rank - 1));
return sortedAsc[idx];
}
/** 比率(保留 4 位小数);分母 ≤ 0 → null。 */
export function ratio(num: number, den: number): number | null {
return den > 0 ? Math.round((num / den) * 10000) / 10000 : null;
}
/** 由时长(毫秒)与模型档位估算额度单位(保留 2 位小数)。 */
export function estimateCostUnits(durationMs: number, model: string): number {
return Math.round((durationMs / 60000) * costPerMin(model) * 100) / 100;
}
/** 空指标(无数据时各项归零,不崩)。 */
export function emptyMetrics(projectId: string | null): Metrics {
return {
projectId,
taskCount: 0,
duration: { count: 0, avgMs: 0, p50Ms: 0, p95Ms: 0, maxMs: 0 },
runs: { total: 0, succeeded: 0, failed: 0, successRate: null },
review: {
reviewTotal: 0, reviewApprove: 0, reviewRate: null,
securityTotal: 0, securityApprove: 0, securityRate: null,
},
retry: { retries: 0, taskCount: 0, retryRate: null, needsAttention: 0 },
byModel: [],
estimated: true,
};
}
+1
View File
@@ -1,4 +1,5 @@
export { Store, StoreError } from './store.js';
export type { CreateProjectInput, CreateTaskInput, PatchProjectInput, PatchTaskInput, ActiveRun } from './store.js';
export type { Metrics, DurationStats, RunStats, ReviewStats, RetryStats, ModelUsage } from '../model/metrics.js';
export { openDb } from './db.js';
export type { DB } from './db.js';
+119
View File
@@ -8,6 +8,10 @@ import type { Project, Task, ApprovalRecord, Run, Event, TaskResult, Autonomy, E
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';
@@ -780,6 +784,121 @@ export class Store {
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),天然排除。