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
+194
View File
@@ -0,0 +1,194 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Store } from '../src/store/index.js';
import {
percentile, ratio, estimateCostUnits, emptyMetrics, costPerMin, DEFAULT_COST_PER_MIN,
} from '../src/model/metrics.js';
import type { RunStatus } from '../src/model/types.js';
function fresh(): Store { return new Store(':memory:'); }
const BASE = 1_700_000_000_000; // 固定基准时刻,避免依赖真实时间
/** 造一条 executor run 并精确设置起止时间(durMs=null → 未结束)。 */
function mkRun(
s: Store, taskId: string,
opts: { status?: RunStatus; startMs?: number; durMs?: number | null } = {},
): string {
const status = opts.status ?? 'succeeded';
const r = s.startRun(taskId, 'executor');
if (status !== 'started') s.finishRun(r.id, status);
const started = new Date(BASE + (opts.startMs ?? 0)).toISOString();
const ended = opts.durMs == null ? null : new Date(BASE + (opts.startMs ?? 0) + opts.durMs).toISOString();
s.db.prepare(`UPDATE runs SET started_at = @s, ended_at = @e WHERE id = @id`)
.run({ s: started, e: ended, id: r.id });
return r.id;
}
// ──────────────── 纯函数 ────────────────
test('metrics 纯函数:percentile / ratio / estimateCostUnits', () => {
assert.equal(percentile([], 50), 0);
assert.equal(percentile([10], 95), 10);
assert.equal(percentile([10, 20, 30, 40], 50), 20); // ceil(0.5*4)=2 → idx 1
assert.equal(percentile([10, 20, 30, 40], 95), 40); // ceil(0.95*4)=4 → idx 3
assert.equal(ratio(1, 2), 0.5);
assert.equal(ratio(2, 3), 0.6667);
assert.equal(ratio(0, 0), null); // 无样本不除零
assert.equal(costPerMin('claude-fable-5'), 1.0);
assert.equal(costPerMin('未知模型'), DEFAULT_COST_PER_MIN);
assert.equal(estimateCostUnits(60_000, 'claude-fable-5'), 1.0); // 1 分钟 × 1.0
assert.equal(estimateCostUnits(120_000, 'claude-opus-4-8'), 1.2); // 2 分钟 × 0.6
});
// ──────────────── 空数据不崩 ────────────────
test('metrics 空数据:全 0 / null,不崩', () => {
const s = fresh();
const m = s.metrics();
assert.equal(m.projectId, null);
assert.equal(m.taskCount, 0);
assert.deepEqual(m.duration, { count: 0, avgMs: 0, p50Ms: 0, p95Ms: 0, maxMs: 0 });
assert.equal(m.runs.successRate, null);
assert.equal(m.review.reviewRate, null);
assert.equal(m.review.securityRate, null);
assert.equal(m.retry.retryRate, null);
assert.equal(m.retry.needsAttention, 0);
assert.deepEqual(m.byModel, []);
assert.equal(m.estimated, true);
s.close();
});
test('metrics:不存在的项目 → emptyMetrics(带 projectId', () => {
const s = fresh();
const m = s.metrics('prj_does_not_exist');
assert.deepEqual(m, emptyMetrics('prj_does_not_exist'));
s.close();
});
// ──────────────── 执行时长 ────────────────
test('metrics 执行时长:avg/p50/p95;未结束与负时长不计入', () => {
const s = fresh();
const p = s.createProject({ name: 'd', repoPath: '/tmp/d-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy' });
mkRun(s, t.id, { status: 'succeeded', startMs: 0, durMs: 1000 });
mkRun(s, t.id, { status: 'succeeded', startMs: 10_000, durMs: 2000 });
mkRun(s, t.id, { status: 'succeeded', startMs: 20_000, durMs: 3000 });
mkRun(s, t.id, { status: 'started', startMs: 30_000, durMs: null }); // 未结束 → 不计
mkRun(s, t.id, { status: 'failed', startMs: 40_000, durMs: -500 }); // 负时长 → 不计
const m = s.metrics(p.id);
assert.equal(m.duration.count, 3);
assert.equal(m.duration.avgMs, 2000);
assert.equal(m.duration.p50Ms, 2000);
assert.equal(m.duration.p95Ms, 3000);
assert.equal(m.duration.maxMs, 3000);
s.close();
});
// ──────────────── 成功率 ────────────────
test('metrics 成功率:仅 succeeded/failed 计入,started/cancelled 不计', () => {
const s = fresh();
const p = s.createProject({ name: 's', repoPath: '/tmp/s-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy' });
mkRun(s, t.id, { status: 'succeeded', durMs: 1000 });
mkRun(s, t.id, { status: 'succeeded', durMs: 1000 });
mkRun(s, t.id, { status: 'failed', durMs: 1000 });
mkRun(s, t.id, { status: 'cancelled', durMs: 1000 });
mkRun(s, t.id, { status: 'started', durMs: null });
const m = s.metrics(p.id);
assert.equal(m.runs.total, 3);
assert.equal(m.runs.succeeded, 2);
assert.equal(m.runs.failed, 1);
assert.equal(m.runs.successRate, 0.6667);
s.close();
});
// ──────────────── 复审通过率 ────────────────
test('metrics 复审通过率:verdict / securityVerdict = approve 占比', () => {
const s = fresh();
const p = s.createProject({ name: 'r', repoPath: '/tmp/r-' + Math.random() });
const base = { branch: null, worktree: null, diffSummary: null, commits: [], prUrl: null, summary: null, securitySummary: null, mergeTaskId: null };
const t1 = s.createTask({ projectId: p.id, title: 'a', complexity: 'easy' });
const t2 = s.createTask({ projectId: p.id, title: 'b', complexity: 'easy' });
const t3 = s.createTask({ projectId: p.id, title: 'c', complexity: 'easy' });
s.setResult(t1.id, { ...base, verdict: 'approve', securityVerdict: 'approve' });
s.setResult(t2.id, { ...base, verdict: 'approve', securityVerdict: 'reject' });
s.setResult(t3.id, { ...base, verdict: 'reject', securityVerdict: null }); // 安全未审 → 不计
const m = s.metrics(p.id);
assert.equal(m.review.reviewTotal, 3);
assert.equal(m.review.reviewApprove, 2);
assert.equal(m.review.reviewRate, 0.6667);
assert.equal(m.review.securityTotal, 2);
assert.equal(m.review.securityApprove, 1);
assert.equal(m.review.securityRate, 0.5);
s.close();
});
// ──────────────── 重试率 / needs_attention ────────────────
test('metrics 重试率:failed→queued 次数 / 任务数;needs_attention 计数', () => {
const s = fresh();
const p = s.createProject({ name: 'rt', repoPath: '/tmp/rt-' + Math.random() });
// 任务 A:跑两轮失败重试(failed→queued ×2
const a = s.createTask({ projectId: p.id, title: 'a', complexity: 'easy' }); // ready
s.transition(a.id, 'queued');
s.transition(a.id, 'executing');
s.transition(a.id, 'failed');
s.transition(a.id, 'queued'); // 重试 1
s.transition(a.id, 'executing');
s.transition(a.id, 'failed');
s.transition(a.id, 'queued'); // 重试 2
// 任务 B:失败后升级 needs_attention(不是 failed→queued,不计重试)
const b = s.createTask({ projectId: p.id, title: 'b', complexity: 'easy' });
s.transition(b.id, 'queued');
s.transition(b.id, 'executing');
s.transition(b.id, 'failed');
s.transition(b.id, 'needs_attention');
const m = s.metrics(p.id);
assert.equal(m.retry.retries, 2);
assert.equal(m.retry.taskCount, 2);
assert.equal(m.retry.retryRate, 1); // 2 / 2
assert.equal(m.retry.needsAttention, 1);
s.close();
});
// ──────────────── 按模型额度估算 ────────────────
test('metrics 按模型额度:时长×档位 粗估,estimated=true', () => {
const s = fresh();
const p = s.createProject({ name: 'mdl', repoPath: '/tmp/mdl-' + Math.random(), model: 'claude-opus-4-8' });
const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy' });
mkRun(s, t.id, { status: 'succeeded', startMs: 0, durMs: 60_000 });
mkRun(s, t.id, { status: 'succeeded', startMs: 100_000, durMs: 60_000 });
const m = s.metrics(p.id);
assert.equal(m.byModel.length, 1);
assert.deepEqual(m.byModel[0], {
model: 'claude-opus-4-8', runs: 2, durationMs: 120_000, estCostUnits: 1.2, estimated: true,
});
assert.equal(m.estimated, true);
s.close();
});
// ──────────────── 项目过滤 ────────────────
test('metrics 项目过滤:只统计指定项目', () => {
const s = fresh();
const pa = s.createProject({ name: 'A', repoPath: '/tmp/A-' + Math.random() });
const pb = s.createProject({ name: 'B', repoPath: '/tmp/B-' + Math.random() });
const ta = s.createTask({ projectId: pa.id, title: 'a', complexity: 'easy' });
const tb = s.createTask({ projectId: pb.id, title: 'b', complexity: 'easy' });
mkRun(s, ta.id, { status: 'succeeded', durMs: 1000 });
mkRun(s, tb.id, { status: 'failed', durMs: 1000 });
const ma = s.metrics(pa.id);
assert.equal(ma.taskCount, 1);
assert.equal(ma.runs.succeeded, 1);
assert.equal(ma.runs.failed, 0);
const all = s.metrics();
assert.equal(all.taskCount, 2);
assert.equal(all.runs.total, 2);
s.close();
});