merge: maestro/tsk_lDx6zd-EbA00 [指标统计:执行时长 / 成功率 / 额度消耗估算]
# Conflicts: # web/style.css
This commit is contained in:
@@ -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