merge: maestro/tsk_lDx6zd-EbA00 [指标统计:执行时长 / 成功率 / 额度消耗估算]
# Conflicts: # web/style.css
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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/failed,started/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 verdict(approve/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-5(hard)最贵 → opus(medium)次之 → sonnet(easy)最省。
|
||||
* 未知模型用 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,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';
|
||||
|
||||
@@ -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),天然排除。
|
||||
|
||||
Reference in New Issue
Block a user