opencode-metrics-plugin 0.3.5 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +1 -0
- package/dist/metrics/analysis/codelines.d.ts +39 -0
- package/dist/metrics/analysis/codelines.js +225 -0
- package/dist/metrics/analysis/hvigor.d.ts +7 -0
- package/dist/metrics/analysis/hvigor.js +20 -0
- package/dist/metrics/engine/engine.js +73 -1
- package/dist/metrics/engine/handlers.js +45 -15
- package/dist/metrics/engine/state.d.ts +26 -0
- package/dist/metrics/engine/state.js +11 -1
- package/dist/metrics/runtime.d.ts +11 -0
- package/dist/metrics/runtime.js +6 -0
- package/dist/metrics/sessionFilter.d.ts +12 -0
- package/dist/metrics/sessionFilter.js +104 -0
- package/dist/metrics/snapshot/flush.d.ts +14 -8
- package/dist/metrics/snapshot/flush.js +105 -4
- package/dist/metrics/snapshot/merge.js +25 -5
- package/dist/metrics/types.d.ts +38 -1
- package/dist/metrics/upload/scenarios.js +3 -1
- package/dist/metrics/upload/uploadManager.d.ts +20 -2
- package/dist/metrics/upload/uploadManager.js +49 -19
- package/dist/plugin.js +4 -1
- package/package.json +1 -1
|
@@ -6,6 +6,12 @@ export interface MetricsRuntimeOptions extends Omit<MetricsEngineOptions, 'enabl
|
|
|
6
6
|
enabled?: boolean;
|
|
7
7
|
/** 事件写盘(events/<sessionId>.log;steps 全文的数据源) */
|
|
8
8
|
eventLogging?: boolean;
|
|
9
|
+
/**
|
|
10
|
+
* 会话 agent 白名单(通配符 `*`,如 ["android-to-hmos-*", "build"]):
|
|
11
|
+
* 根会话的 agent 命中任一模式才记录(快照/事件日志/上传全不产生),子会话随父判定;
|
|
12
|
+
* 缺省/空数组 = 不过滤,记录全部会话(向后兼容)。
|
|
13
|
+
*/
|
|
14
|
+
agents?: string[];
|
|
9
15
|
/** 上传子系统配置(DESIGN.md §13.8);enabled:true 才会创建管理器 */
|
|
10
16
|
upload?: UploadConfig;
|
|
11
17
|
}
|
|
@@ -18,6 +24,11 @@ export declare function createMetricsRuntime(opts?: MetricsRuntimeOptions): {
|
|
|
18
24
|
eventLogger: import("./eventlog/event-logger.js").EventLogger | null;
|
|
19
25
|
engine: import("./types.js").MetricsEngine;
|
|
20
26
|
uploadManager: import("./upload/uploadManager.js").UploadManager | null;
|
|
27
|
+
sessionFilter: {
|
|
28
|
+
active: boolean;
|
|
29
|
+
shouldTrackEvent: (event: import("./sessionFilter.js").SessionFilterEvent) => boolean;
|
|
30
|
+
shouldTrackSession: (sessionId: string) => boolean;
|
|
31
|
+
};
|
|
21
32
|
/** opencode event hook:`async ({ event }) => { runtime.event({ event }) }` */
|
|
22
33
|
event(input: {
|
|
23
34
|
event: {
|
package/dist/metrics/runtime.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createMetricsEngine } from './engine/engine.js';
|
|
2
2
|
import { createEventLogger } from './eventlog/event-logger.js';
|
|
3
|
+
import { createSessionFilter } from './sessionFilter.js';
|
|
3
4
|
import { resolveDirs } from './dirs.js';
|
|
4
5
|
import { createUploadManager } from './upload/uploadManager.js';
|
|
5
6
|
/**
|
|
@@ -17,6 +18,8 @@ export function createMetricsRuntime(opts = {}) {
|
|
|
17
18
|
const eventLogger = (opts.eventLogging ?? true)
|
|
18
19
|
? createEventLogger(true, false, { eventsDir: dirs.eventsDir })
|
|
19
20
|
: null;
|
|
21
|
+
// 会话 agent 过滤:event 入口统一拦截,事件日志/引擎/上传三个子系统一次挡住
|
|
22
|
+
const sessionFilter = createSessionFilter(opts.agents);
|
|
20
23
|
// 上传管理器旁路挂在 runtime.event 上(在 engine.ingest 之后,保证触发时快照已落盘)
|
|
21
24
|
const uploadManager = createUploadManager({
|
|
22
25
|
config: opts.upload,
|
|
@@ -32,8 +35,11 @@ export function createMetricsRuntime(opts = {}) {
|
|
|
32
35
|
eventLogger,
|
|
33
36
|
engine,
|
|
34
37
|
uploadManager,
|
|
38
|
+
sessionFilter,
|
|
35
39
|
/** opencode event hook:`async ({ event }) => { runtime.event({ event }) }` */
|
|
36
40
|
event(input) {
|
|
41
|
+
if (!sessionFilter.shouldTrackEvent(input.event))
|
|
42
|
+
return;
|
|
37
43
|
eventLogger?.log(input.event);
|
|
38
44
|
engine.ingest(input.event);
|
|
39
45
|
uploadManager?.observe(input.event);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface SessionFilterEvent {
|
|
2
|
+
type: string;
|
|
3
|
+
properties?: Record<string, unknown>;
|
|
4
|
+
}
|
|
5
|
+
/** 通配符转正则:* → 任意串,其余字符按字面匹配;整串匹配,大小写敏感 */
|
|
6
|
+
export declare function compileAgentPatterns(patterns: readonly string[]): RegExp[];
|
|
7
|
+
export declare function createSessionFilter(agents?: readonly string[]): {
|
|
8
|
+
active: boolean;
|
|
9
|
+
shouldTrackEvent: (event: SessionFilterEvent) => boolean;
|
|
10
|
+
shouldTrackSession: (sessionId: string) => boolean;
|
|
11
|
+
};
|
|
12
|
+
export type SessionFilter = ReturnType<typeof createSessionFilter>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// 会话 agent 过滤器:按根会话的 agent 通配匹配决定是否记录,子会话随父判定。
|
|
2
|
+
//
|
|
3
|
+
// 设计要点:
|
|
4
|
+
// - 判定信号:session.created / session.updated 的 properties.info.agent(实测会话首个事件即携带);
|
|
5
|
+
// message.updated 的 info.agent 作兜底(session 事件缺 agent 时)。agent 首次非空即终判,不再翻案。
|
|
6
|
+
// - fail-open:agent 始终未知的会话默认记录,防止 opencode 事件结构变化导致漏采。
|
|
7
|
+
// - 子会话(info.parentID)不独立判定,解析到根会话后跟随其判定;父未判定时同样放行。
|
|
8
|
+
// - 未配置 agents(缺省/空数组/全空串)时过滤器不激活,所有事件直通(向后兼容)。
|
|
9
|
+
/** 通配符转正则:* → 任意串,其余字符按字面匹配;整串匹配,大小写敏感 */
|
|
10
|
+
export function compileAgentPatterns(patterns) {
|
|
11
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
12
|
+
return patterns.map((p) => new RegExp(`^${p.split("*").map(escape).join(".*")}$`));
|
|
13
|
+
}
|
|
14
|
+
export function createSessionFilter(agents) {
|
|
15
|
+
const patterns = (agents ?? []).filter((p) => typeof p === "string" && p.length > 0);
|
|
16
|
+
const active = patterns.length > 0;
|
|
17
|
+
const regexes = active ? compileAgentPatterns(patterns) : [];
|
|
18
|
+
const decisions = new Map();
|
|
19
|
+
const childToParent = new Map();
|
|
20
|
+
function matchesAgent(agent) {
|
|
21
|
+
return regexes.some((re) => re.test(agent));
|
|
22
|
+
}
|
|
23
|
+
/** 沿 parentID 链向上找根会话(防环,最多 10 层) */
|
|
24
|
+
function resolveRoot(sessionId) {
|
|
25
|
+
let root = sessionId;
|
|
26
|
+
for (let i = 0; i < 10; i++) {
|
|
27
|
+
const parent = childToParent.get(root);
|
|
28
|
+
if (!parent)
|
|
29
|
+
break;
|
|
30
|
+
root = parent;
|
|
31
|
+
}
|
|
32
|
+
return root;
|
|
33
|
+
}
|
|
34
|
+
function decide(rootId, agent) {
|
|
35
|
+
const existing = decisions.get(rootId);
|
|
36
|
+
if (existing === "in" || existing === "out")
|
|
37
|
+
return;
|
|
38
|
+
decisions.set(rootId, matchesAgent(agent) ? "in" : "out");
|
|
39
|
+
}
|
|
40
|
+
/** 会话级判定:供 system.transform 等按 sessionId 查询的入口 */
|
|
41
|
+
function shouldTrackSession(sessionId) {
|
|
42
|
+
if (!active)
|
|
43
|
+
return true;
|
|
44
|
+
return decisions.get(resolveRoot(sessionId)) !== "out";
|
|
45
|
+
}
|
|
46
|
+
function extractSessionId(props) {
|
|
47
|
+
const sessionID = props.sessionID;
|
|
48
|
+
if (typeof sessionID === "string" && sessionID)
|
|
49
|
+
return sessionID;
|
|
50
|
+
const info = props.info;
|
|
51
|
+
const id = info?.id;
|
|
52
|
+
if (typeof id === "string" && id)
|
|
53
|
+
return id;
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* 事件级判定 + 状态机推进;返回 false 表示该事件所属会话不在记录范围。
|
|
58
|
+
* 无法归属会话的事件放行(引擎侧自会丢弃)。
|
|
59
|
+
*/
|
|
60
|
+
function shouldTrackEvent(event) {
|
|
61
|
+
if (!active)
|
|
62
|
+
return true;
|
|
63
|
+
const props = event.properties;
|
|
64
|
+
if (!props)
|
|
65
|
+
return true;
|
|
66
|
+
if (event.type === "session.created" || event.type === "session.updated") {
|
|
67
|
+
const info = props.info;
|
|
68
|
+
const id = info?.id;
|
|
69
|
+
if (typeof id === "string" && id) {
|
|
70
|
+
const parentID = info.parentID;
|
|
71
|
+
if (typeof parentID === "string" && parentID) {
|
|
72
|
+
childToParent.set(id, parentID);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
const agent = info.agent;
|
|
76
|
+
if (typeof agent === "string" && agent)
|
|
77
|
+
decide(id, agent);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
else if (event.type === "message.updated") {
|
|
82
|
+
// 兜底:session 事件一直没带 agent 时,用消息的 agent 定案(仅根会话)
|
|
83
|
+
const sessionID = props.sessionID;
|
|
84
|
+
const info = props.info;
|
|
85
|
+
const agent = info?.agent;
|
|
86
|
+
if (typeof sessionID === "string" && sessionID && typeof agent === "string" && agent && !childToParent.has(sessionID)) {
|
|
87
|
+
decide(resolveRoot(sessionID), agent);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else if (event.type === "session.deleted") {
|
|
91
|
+
// 清理:根会话删除即移除判定;子会话删除移除映射
|
|
92
|
+
const sid = extractSessionId(props);
|
|
93
|
+
if (sid) {
|
|
94
|
+
childToParent.delete(sid);
|
|
95
|
+
decisions.delete(sid);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const sid = extractSessionId(props);
|
|
99
|
+
if (!sid)
|
|
100
|
+
return true;
|
|
101
|
+
return shouldTrackSession(sid);
|
|
102
|
+
}
|
|
103
|
+
return { active, shouldTrackEvent, shouldTrackSession };
|
|
104
|
+
}
|
|
@@ -2,15 +2,12 @@ import type { MetricsDirs } from "../dirs.js";
|
|
|
2
2
|
import type { SessionMetricsState } from "../engine/state.js";
|
|
3
3
|
import type { RoundSnapshot, MetricsOutput } from "../types.js";
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* 刷新会话的工程代码行多维统计(30s 节流;候选根集合变化立即重扫)。
|
|
6
|
+
* 扫描口径见 analysis/codelines.ts;找不到鸿蒙工程根时全维度保持 0。
|
|
7
7
|
*
|
|
8
|
-
* @param
|
|
9
|
-
* @param state 会话状态,写盘成功后原地刷新差量基线
|
|
10
|
-
* @param now 本次快照的结束时间(ms)
|
|
11
|
-
* @param dirs 输出目录;缺省用进程级默认目录
|
|
12
|
-
* @returns 本次由内存态构建的快照(未与旧文件合并的那份)
|
|
8
|
+
* @param state 会话状态(结果原地写入 state.codeLines)
|
|
13
9
|
*/
|
|
10
|
+
export declare function refreshCodeLineStats(state: SessionMetricsState): void;
|
|
14
11
|
declare function flushMetrics(sessionId: string, state: SessionMetricsState, now: number, dirs?: Partial<MetricsDirs>): MetricsOutput;
|
|
15
12
|
/**
|
|
16
13
|
* 处理一轮会话结束:保存本轮 round 快照并落盘。
|
|
@@ -19,6 +16,15 @@ declare function flushMetrics(sessionId: string, state: SessionMetricsState, now
|
|
|
19
16
|
* @param sessionId 会话 id
|
|
20
17
|
* @param dirs 输出目录;缺省用进程级默认目录
|
|
21
18
|
*/
|
|
19
|
+
/**
|
|
20
|
+
* 轮闭合前的等待结算(0.4.1 活跃口径):把仍挂起的 question/permission/retry
|
|
21
|
+
* 截断入本轮累加器,并把挂起计时重锚到闭合时刻——等待若延续到下一轮,
|
|
22
|
+
* 只在新轮计尾段,跨轮拆分总量不丢、不双计。
|
|
23
|
+
*
|
|
24
|
+
* @param state 会话状态(就地结算累加器与挂起计时)
|
|
25
|
+
* @param now 轮闭合时刻(ms)
|
|
26
|
+
*/
|
|
27
|
+
export declare function finalizeRoundWaits(state: SessionMetricsState, now: number): void;
|
|
22
28
|
/**
|
|
23
29
|
* 从当前轮累计态构建轮快照;空轮(无 token 且无工具调用)返回 null(天然防重)。
|
|
24
30
|
*
|
|
@@ -26,7 +32,7 @@ declare function flushMetrics(sessionId: string, state: SessionMetricsState, now
|
|
|
26
32
|
* @param now 结算时刻(ms)
|
|
27
33
|
*/
|
|
28
34
|
export declare function buildRoundSnapshot(state: SessionMetricsState, now: number): RoundSnapshot | null;
|
|
29
|
-
/** 重置轮累计态,开始新轮(不改动 stage
|
|
35
|
+
/** 重置轮累计态,开始新轮(不改动 stage 相关状态;等待挂起计时由 finalizeRoundWaits 结算)。 */
|
|
30
36
|
export declare function resetRoundState(state: SessionMetricsState, now: number): void;
|
|
31
37
|
declare function handleSessionIdle(state: SessionMetricsState, sessionId: string, dirs?: Partial<MetricsDirs>): MetricsOutput | undefined;
|
|
32
38
|
export { flushMetrics, handleSessionIdle };
|
|
@@ -5,6 +5,7 @@ import { createTokenUsage } from "../types.js";
|
|
|
5
5
|
import { getMetricsDir } from "../dirs.js";
|
|
6
6
|
import { buildSteps, extractStepContent } from "./steps.js";
|
|
7
7
|
import { mergeMetricsOutput } from "./merge.js";
|
|
8
|
+
import { discoverHarmonyRoots, scanCodeLines } from "../analysis/codelines.js";
|
|
8
9
|
function filterByKeys(source, keys) {
|
|
9
10
|
const result = new Map();
|
|
10
11
|
for (const key of keys) {
|
|
@@ -24,7 +25,40 @@ function filterByKeys(source, keys) {
|
|
|
24
25
|
* @param dirs 输出目录;缺省用进程级默认目录
|
|
25
26
|
* @returns 本次由内存态构建的快照(未与旧文件合并的那份)
|
|
26
27
|
*/
|
|
28
|
+
/** 代码行扫描节流间隔:活跃会话 checkpoint 频率远高于此,避免重复扫盘 */
|
|
29
|
+
const CODE_SCAN_THROTTLE_MS = 30_000;
|
|
30
|
+
/**
|
|
31
|
+
* 刷新会话的工程代码行多维统计(30s 节流;候选根集合变化立即重扫)。
|
|
32
|
+
* 扫描口径见 analysis/codelines.ts;找不到鸿蒙工程根时全维度保持 0。
|
|
33
|
+
*
|
|
34
|
+
* @param state 会话状态(结果原地写入 state.codeLines)
|
|
35
|
+
*/
|
|
36
|
+
export function refreshCodeLineStats(state) {
|
|
37
|
+
try {
|
|
38
|
+
const candidates = [state.sessionMeta.workingDirectory, ...state.codeRootCandidates]
|
|
39
|
+
.filter((dir) => typeof dir === "string" && dir.length > 0);
|
|
40
|
+
const rootsKey = [...candidates]
|
|
41
|
+
.map((dir) => (process.platform === "win32" ? dir.toLowerCase() : dir))
|
|
42
|
+
.sort()
|
|
43
|
+
.join("|");
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
if (state._lastCodeScanAt > 0 &&
|
|
46
|
+
now - state._lastCodeScanAt < CODE_SCAN_THROTTLE_MS &&
|
|
47
|
+
state._codeScanRootsKey === rootsKey) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const roots = discoverHarmonyRoots(candidates, state.startTime);
|
|
51
|
+
state.codeLines = scanCodeLines(roots);
|
|
52
|
+
state._lastCodeScanAt = now;
|
|
53
|
+
state._codeScanRootsKey = rootsKey;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// 扫描失败保持上次结果(或全 0),不影响快照其余字段
|
|
57
|
+
}
|
|
58
|
+
}
|
|
27
59
|
function flushMetrics(sessionId, state, now, dirs) {
|
|
60
|
+
// 代码行多维统计(节流扫描,见 refreshCodeLineStats)
|
|
61
|
+
refreshCodeLineStats(state);
|
|
28
62
|
// Finalize agent usage for current agent
|
|
29
63
|
if (state.agent.current) {
|
|
30
64
|
const elapsed = now - state.agent.lastSwitchTime;
|
|
@@ -36,6 +70,16 @@ function flushMetrics(sessionId, state, now, dirs) {
|
|
|
36
70
|
}
|
|
37
71
|
const endTime = now;
|
|
38
72
|
const duration = endTime - state.startTime;
|
|
73
|
+
// AI 活跃统计(0.4.1 起):Σ 已闭合轮 activeDuration + 等待明细(进行中的开轮不计,闭合后下次 flush 补齐)
|
|
74
|
+
const aiActiveDuration = state.rounds.reduce((sum, r) => sum + (r.activeDuration ?? r.duration ?? 0), 0);
|
|
75
|
+
const roundsDuration = state.rounds.reduce((sum, r) => sum + (r.duration || 0), 0);
|
|
76
|
+
const waitBreakdown = {
|
|
77
|
+
questionMs: state.rounds.reduce((sum, r) => sum + (r.questionMs ?? 0), 0),
|
|
78
|
+
permissionMs: state.rounds.reduce((sum, r) => sum + (r.permissionMs ?? 0), 0),
|
|
79
|
+
retryMs: state.rounds.reduce((sum, r) => sum + (r.retryMs ?? 0), 0),
|
|
80
|
+
retryCount: state.rounds.reduce((sum, r) => sum + (r.retryCount ?? 0), 0),
|
|
81
|
+
otherIdleMs: Math.max(0, duration - roundsDuration),
|
|
82
|
+
};
|
|
39
83
|
// Calculate cache hit rate
|
|
40
84
|
const denom = state.tokens.input + state.tokens.cacheRead;
|
|
41
85
|
const cacheHitRate = denom > 0 ? state.tokens.cacheRead / denom : 0;
|
|
@@ -223,6 +267,9 @@ function flushMetrics(sessionId, state, now, dirs) {
|
|
|
223
267
|
startTime: state.startTime,
|
|
224
268
|
endTime,
|
|
225
269
|
duration,
|
|
270
|
+
aiActiveDuration,
|
|
271
|
+
aiActiveRatio: duration > 0 ? aiActiveDuration / duration : 0,
|
|
272
|
+
waitBreakdown,
|
|
226
273
|
systemPrompts,
|
|
227
274
|
rounds: state.rounds,
|
|
228
275
|
tokens: (() => {
|
|
@@ -260,7 +307,11 @@ function flushMetrics(sessionId, state, now, dirs) {
|
|
|
260
307
|
})),
|
|
261
308
|
header,
|
|
262
309
|
codeStats: {
|
|
263
|
-
etsLines: state.
|
|
310
|
+
etsLines: state.codeLines.etsLines,
|
|
311
|
+
cLines: state.codeLines.cLines,
|
|
312
|
+
cppLines: state.codeLines.cppLines,
|
|
313
|
+
testLines: state.codeLines.testLines,
|
|
314
|
+
businessCodeLines: state.codeLines.businessCodeLines,
|
|
264
315
|
buildSuccess: state.compileStats.lastBuildSuccess,
|
|
265
316
|
fixCompileCount: state.compileStats.hvigorwCalls,
|
|
266
317
|
totalCompileErrors: state.compileStats.hvigorwErrors,
|
|
@@ -394,6 +445,33 @@ function flushMetrics(sessionId, state, now, dirs) {
|
|
|
394
445
|
* @param sessionId 会话 id
|
|
395
446
|
* @param dirs 输出目录;缺省用进程级默认目录
|
|
396
447
|
*/
|
|
448
|
+
/**
|
|
449
|
+
* 轮闭合前的等待结算(0.4.1 活跃口径):把仍挂起的 question/permission/retry
|
|
450
|
+
* 截断入本轮累加器,并把挂起计时重锚到闭合时刻——等待若延续到下一轮,
|
|
451
|
+
* 只在新轮计尾段,跨轮拆分总量不丢、不双计。
|
|
452
|
+
*
|
|
453
|
+
* @param state 会话状态(就地结算累加器与挂起计时)
|
|
454
|
+
* @param now 轮闭合时刻(ms)
|
|
455
|
+
*/
|
|
456
|
+
export function finalizeRoundWaits(state, now) {
|
|
457
|
+
// 未答的权限询问:截断入本轮并移除(后续 replied 到达时无条目即忽略,防跨轮双计)
|
|
458
|
+
for (const [id, pending] of state._pendingPermissionAsks) {
|
|
459
|
+
state._roundPermissionMs += Math.max(0, now - pending.askedAt);
|
|
460
|
+
state._pendingPermissionAsks.delete(id);
|
|
461
|
+
}
|
|
462
|
+
// 挂起中的限流重试:截断入本轮,起点重锚(计数已在 retry 事件时入账,此处只补时长)
|
|
463
|
+
if (state._retryStartedAt !== null) {
|
|
464
|
+
state._roundRetryMs += Math.max(0, now - state._retryStartedAt);
|
|
465
|
+
state._retryStartedAt = now;
|
|
466
|
+
}
|
|
467
|
+
// 仍在等待回答的 question 工具:截断入本轮,startMs 重锚(completed 事件只补尾段)
|
|
468
|
+
for (const tc of state.toolCallMap.values()) {
|
|
469
|
+
if (tc.tool === "question" && tc.status === "running" && tc.startMs > 0 && tc.startMs < now) {
|
|
470
|
+
state._roundQuestionMs += now - tc.startMs;
|
|
471
|
+
tc.startMs = now;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
397
475
|
/**
|
|
398
476
|
* 从当前轮累计态构建轮快照;空轮(无 token 且无工具调用)返回 null(天然防重)。
|
|
399
477
|
*
|
|
@@ -401,16 +479,34 @@ function flushMetrics(sessionId, state, now, dirs) {
|
|
|
401
479
|
* @param now 结算时刻(ms)
|
|
402
480
|
*/
|
|
403
481
|
export function buildRoundSnapshot(state, now) {
|
|
404
|
-
|
|
482
|
+
// 空轮判定:无 token、无工具调用且无任何等待(限流/审批/提问)才视为空轮丢弃。
|
|
483
|
+
// 仅含等待的轮(如纯限流挂起)仍占用墙钟,须保留以维持 activeDuration 扣减恒等式。
|
|
484
|
+
const isEmptyRound = state._roundTokens.total === 0
|
|
485
|
+
&& state._roundToolCalls === 0
|
|
486
|
+
&& state._roundQuestionMs === 0
|
|
487
|
+
&& state._roundPermissionMs === 0
|
|
488
|
+
&& state._roundRetryMs === 0
|
|
489
|
+
&& state._roundRetryCount === 0;
|
|
405
490
|
if (isEmptyRound)
|
|
406
491
|
return null;
|
|
407
492
|
const firstTokenLatency = state._hasTextPart && state._firstEventTime > 0
|
|
408
493
|
? state._firstTextTime - state._firstEventTime
|
|
409
494
|
: 0;
|
|
495
|
+
const duration = now - state._roundStartTime;
|
|
496
|
+
// 活跃口径(0.4.1 起):轮墙钟减已知等待,各项 clamp 到 [0, duration],合计下限 0
|
|
497
|
+
const clampWait = (v) => Math.max(0, Math.min(v, duration));
|
|
498
|
+
const questionMs = clampWait(state._roundQuestionMs);
|
|
499
|
+
const permissionMs = clampWait(state._roundPermissionMs);
|
|
500
|
+
const retryMs = clampWait(state._roundRetryMs);
|
|
410
501
|
return {
|
|
411
502
|
startTime: state._roundStartTime,
|
|
412
503
|
roundIndex: state.rounds.length,
|
|
413
|
-
duration
|
|
504
|
+
duration,
|
|
505
|
+
activeDuration: Math.max(0, duration - questionMs - permissionMs - retryMs),
|
|
506
|
+
questionMs,
|
|
507
|
+
permissionMs,
|
|
508
|
+
retryMs,
|
|
509
|
+
retryCount: state._roundRetryCount,
|
|
414
510
|
firstTokenLatency,
|
|
415
511
|
tokens: { ...state._roundTokens },
|
|
416
512
|
toolCalls: state._roundToolCalls,
|
|
@@ -418,7 +514,7 @@ export function buildRoundSnapshot(state, now) {
|
|
|
418
514
|
userMessage: state._pendingUserMessage.length > 0 ? [...state._pendingUserMessage] : undefined,
|
|
419
515
|
};
|
|
420
516
|
}
|
|
421
|
-
/** 重置轮累计态,开始新轮(不改动 stage
|
|
517
|
+
/** 重置轮累计态,开始新轮(不改动 stage 相关状态;等待挂起计时由 finalizeRoundWaits 结算)。 */
|
|
422
518
|
export function resetRoundState(state, now) {
|
|
423
519
|
state._roundStartTime = now;
|
|
424
520
|
state._firstEventTime = 0;
|
|
@@ -428,6 +524,10 @@ export function resetRoundState(state, now) {
|
|
|
428
524
|
state._roundTokens = createTokenUsage();
|
|
429
525
|
state._roundToolCalls = 0;
|
|
430
526
|
state._roundErrors = 0;
|
|
527
|
+
state._roundQuestionMs = 0;
|
|
528
|
+
state._roundPermissionMs = 0;
|
|
529
|
+
state._roundRetryMs = 0;
|
|
530
|
+
state._roundRetryCount = 0;
|
|
431
531
|
state._pendingUserMessage = [];
|
|
432
532
|
state._roundFirstBuildTracked = false;
|
|
433
533
|
}
|
|
@@ -437,6 +537,7 @@ function handleSessionIdle(state, sessionId, dirs) {
|
|
|
437
537
|
if (state._roundIdleProcessed) {
|
|
438
538
|
return;
|
|
439
539
|
}
|
|
540
|
+
finalizeRoundWaits(state, now);
|
|
440
541
|
const round = buildRoundSnapshot(state, now);
|
|
441
542
|
if (round)
|
|
442
543
|
state.rounds.push(round);
|
|
@@ -57,7 +57,6 @@ function mergeChildMetrics(parent, child, meta) {
|
|
|
57
57
|
// Merge compileStats (additive)
|
|
58
58
|
parent.compileStats.hvigorwCalls += child.compileStats.hvigorwCalls;
|
|
59
59
|
parent.compileStats.hvigorwErrors += child.compileStats.hvigorwErrors;
|
|
60
|
-
parent.compileStats.etsLines += child.compileStats.etsLines;
|
|
61
60
|
// lastBuildSuccess: take child's value if child had any builds
|
|
62
61
|
if (child.compileStats.hvigorwCalls > 0 || child.compileStats.hvigorwErrors > 0) {
|
|
63
62
|
parent.compileStats.lastBuildSuccess = child.compileStats.lastBuildSuccess;
|
|
@@ -207,6 +206,18 @@ export function mergeMetricsOutput(existing, fresh, baseline) {
|
|
|
207
206
|
...fresh.rounds.filter(r => !existingRoundIndices.has(r.roundIndex)),
|
|
208
207
|
].sort((a, b) => a.roundIndex - b.roundIndex);
|
|
209
208
|
})();
|
|
209
|
+
// AI 活跃统计(0.4.1 起):从合并后的轮数组重算——fresh 重启后可能只含新段,
|
|
210
|
+
// 不能直接展开 fresh 汇总;旧轮(0.4.0 快照)缺 activeDuration 回退按 duration 计(视为全活跃),
|
|
211
|
+
// 等待明细缺省按 0。与 planning 同思路:合并方从合并后明细重新推导。
|
|
212
|
+
const aiActiveDuration = rounds.reduce((sum, r) => sum + (r.activeDuration ?? r.duration ?? 0), 0);
|
|
213
|
+
const roundsDuration = rounds.reduce((sum, r) => sum + (r.duration || 0), 0);
|
|
214
|
+
const waitBreakdown = {
|
|
215
|
+
questionMs: rounds.reduce((sum, r) => sum + (r.questionMs ?? 0), 0),
|
|
216
|
+
permissionMs: rounds.reduce((sum, r) => sum + (r.permissionMs ?? 0), 0),
|
|
217
|
+
retryMs: rounds.reduce((sum, r) => sum + (r.retryMs ?? 0), 0),
|
|
218
|
+
retryCount: rounds.reduce((sum, r) => sum + (r.retryCount ?? 0), 0),
|
|
219
|
+
otherIdleMs: Math.max(0, duration - roundsDuration),
|
|
220
|
+
};
|
|
210
221
|
// Steps:进程重启后 orderCounter 从 0 重编,fresh 的 index 与磁盘 existing 撞车,
|
|
211
222
|
// 且时间上更晚的新步骤被排到列表头部;resume 重灌轮的时间戳全新,index 与时间戳都无法跨进程关联。
|
|
212
223
|
// 统一策略:按 startTime:endTime 去重(防同进程重放,fresh wins)后,
|
|
@@ -494,18 +505,24 @@ export function mergeMetricsOutput(existing, fresh, baseline) {
|
|
|
494
505
|
};
|
|
495
506
|
})();
|
|
496
507
|
// codeStats 顶层分两类处理:累计型(fixCompileCount/totalCompileErrors)按差量并入;
|
|
497
|
-
//
|
|
498
|
-
//
|
|
508
|
+
// 快照型(代码行五维/buildSuccess/firstBuildPassRate/firstBuildPerRound)在 fresh
|
|
509
|
+
// 有值(>0)时取 fresh——工程全量扫描的时效值,fresh 无值保留 existing
|
|
510
|
+
// (重启进程未扫到工程是"不知道"而非"清零")。
|
|
499
511
|
const fixCompileCount = existing.codeStats.fixCompileCount
|
|
500
512
|
+ Math.max(0, fresh.codeStats.fixCompileCount - (baseline?.fixCompileCount ?? 0));
|
|
501
513
|
const totalCompileErrors = existing.codeStats.totalCompileErrors
|
|
502
514
|
+ Math.max(0, fresh.codeStats.totalCompileErrors - (baseline?.totalCompileErrors ?? 0));
|
|
503
|
-
const freshHasBuild = fresh.codeStats.
|
|
515
|
+
const freshHasBuild = fresh.codeStats.businessCodeLines > 0
|
|
504
516
|
|| fresh.codeStats.fixCompileCount > (baseline?.fixCompileCount ?? 0)
|
|
505
517
|
|| totalCompileErrors > existing.codeStats.totalCompileErrors
|
|
506
518
|
|| fresh.codeStats.firstBuildPerRound.length > 0;
|
|
519
|
+
const pickLineCount = (freshValue, existingValue) => (freshValue ?? 0) > 0 ? freshValue : (existingValue ?? 0);
|
|
507
520
|
const codeStats = {
|
|
508
|
-
etsLines: fresh.codeStats.etsLines
|
|
521
|
+
etsLines: pickLineCount(fresh.codeStats.etsLines, existing.codeStats.etsLines),
|
|
522
|
+
cLines: pickLineCount(fresh.codeStats.cLines, existing.codeStats.cLines),
|
|
523
|
+
cppLines: pickLineCount(fresh.codeStats.cppLines, existing.codeStats.cppLines),
|
|
524
|
+
testLines: pickLineCount(fresh.codeStats.testLines, existing.codeStats.testLines),
|
|
525
|
+
businessCodeLines: pickLineCount(fresh.codeStats.businessCodeLines, existing.codeStats.businessCodeLines),
|
|
509
526
|
buildSuccess: freshHasBuild ? fresh.codeStats.buildSuccess : existing.codeStats.buildSuccess,
|
|
510
527
|
fixCompileCount,
|
|
511
528
|
totalCompileErrors,
|
|
@@ -526,6 +543,9 @@ export function mergeMetricsOutput(existing, fresh, baseline) {
|
|
|
526
543
|
startTime,
|
|
527
544
|
endTime: fresh.endTime,
|
|
528
545
|
duration,
|
|
546
|
+
aiActiveDuration,
|
|
547
|
+
aiActiveRatio: duration > 0 ? aiActiveDuration / duration : 0,
|
|
548
|
+
waitBreakdown,
|
|
529
549
|
systemPrompts,
|
|
530
550
|
rounds,
|
|
531
551
|
tokens,
|
package/dist/metrics/types.d.ts
CHANGED
|
@@ -85,7 +85,6 @@ export interface CompileStats {
|
|
|
85
85
|
hvigorwCalls: number;
|
|
86
86
|
hvigorwErrors: number;
|
|
87
87
|
lastBuildSuccess: boolean;
|
|
88
|
-
etsLines: number;
|
|
89
88
|
firstBuildPerRound: boolean[];
|
|
90
89
|
errorCodes: Map<string, {
|
|
91
90
|
count: number;
|
|
@@ -163,12 +162,35 @@ export interface RoundSnapshot {
|
|
|
163
162
|
startTime?: number;
|
|
164
163
|
roundIndex: number;
|
|
165
164
|
duration: number;
|
|
165
|
+
/** AI 活跃时长(0.4.1 起)= duration − questionMs − permissionMs − retryMs(下限 0);旧快照缺省,合并回退按 duration 计 */
|
|
166
|
+
activeDuration?: number;
|
|
167
|
+
/** question 工具等待用户回答累计(ms,0.4.1 起) */
|
|
168
|
+
questionMs?: number;
|
|
169
|
+
/** 权限审批等待累计(ms,0.4.1 起) */
|
|
170
|
+
permissionMs?: number;
|
|
171
|
+
/** 限流重试挂起累计(ms,0.4.1 起) */
|
|
172
|
+
retryMs?: number;
|
|
173
|
+
/** 限流重试次数(0.4.1 起) */
|
|
174
|
+
retryCount?: number;
|
|
166
175
|
firstTokenLatency: number;
|
|
167
176
|
tokens: TokenUsage;
|
|
168
177
|
toolCalls: number;
|
|
169
178
|
errors: number;
|
|
170
179
|
userMessage?: string[];
|
|
171
180
|
}
|
|
181
|
+
/** 会话级等待扣减明细(0.4.1 起):duration = aiActiveDuration + 各等待项之和 */
|
|
182
|
+
export interface WaitBreakdown {
|
|
183
|
+
/** question 工具等待用户回答累计(ms) */
|
|
184
|
+
questionMs: number;
|
|
185
|
+
/** 权限审批等待累计(ms) */
|
|
186
|
+
permissionMs: number;
|
|
187
|
+
/** 限流重试挂起累计(ms) */
|
|
188
|
+
retryMs: number;
|
|
189
|
+
/** 限流重试次数 */
|
|
190
|
+
retryCount: number;
|
|
191
|
+
/** 轮间空闲:墙钟未被任何已闭合轮覆盖的部分(ms) */
|
|
192
|
+
otherIdleMs: number;
|
|
193
|
+
}
|
|
172
194
|
export interface SubAgentOutput {
|
|
173
195
|
sessionId: string;
|
|
174
196
|
agent: string;
|
|
@@ -201,6 +223,12 @@ export interface MetricsOutput {
|
|
|
201
223
|
startTime: number;
|
|
202
224
|
endTime: number;
|
|
203
225
|
duration: number;
|
|
226
|
+
/** AI 活跃时长(0.4.1 起):Σ 轮 activeDuration(轮墙钟减去已知等待,step 间死时间计入活跃) */
|
|
227
|
+
aiActiveDuration?: number;
|
|
228
|
+
/** AI 活跃占比 = aiActiveDuration / duration(0-1,0.4.1 起) */
|
|
229
|
+
aiActiveRatio?: number;
|
|
230
|
+
/** 等待扣减明细(0.4.1 起) */
|
|
231
|
+
waitBreakdown?: WaitBreakdown;
|
|
204
232
|
systemPrompts: Record<string, string[]>;
|
|
205
233
|
rounds: RoundSnapshot[];
|
|
206
234
|
tokens: TokenUsage & {
|
|
@@ -235,7 +263,16 @@ export interface MetricsOutput {
|
|
|
235
263
|
}>;
|
|
236
264
|
header: SessionMeta;
|
|
237
265
|
codeStats: {
|
|
266
|
+
/** 排除测试的 .ets 行数(0.4.2 起为工程全量扫描口径,非 write 工具累计) */
|
|
238
267
|
etsLines: number;
|
|
268
|
+
/** 排除测试的 C(.c/.h)行数 */
|
|
269
|
+
cLines: number;
|
|
270
|
+
/** 排除测试的 C++(.cpp/.cc/.cxx/.hpp 等)行数 */
|
|
271
|
+
cppLines: number;
|
|
272
|
+
/** 测试代码行数(ohosTest、test 等目录子树与 .test.、.spec. 命名文件,任意语言) */
|
|
273
|
+
testLines: number;
|
|
274
|
+
/** 实际业务代码总量 = ets + C + C++ + 测试(= 全量;构建/配置文件不计入) */
|
|
275
|
+
businessCodeLines: number;
|
|
239
276
|
buildSuccess: boolean;
|
|
240
277
|
fixCompileCount: number;
|
|
241
278
|
totalCompileErrors: number;
|
|
@@ -6,7 +6,9 @@ const BUILTIN_SCENARIOS = {
|
|
|
6
6
|
events: ["dispose", "session.deleted"],
|
|
7
7
|
tools: ["task"],
|
|
8
8
|
},
|
|
9
|
-
|
|
9
|
+
// git 与 commit 之间允许 ≤4 个选项(如 -c user.name=x、-C <path>、--no-verify),
|
|
10
|
+
// 但选项必须以 - 开头——避免把 "git log --grep=commit" 一类误判为提交
|
|
11
|
+
"git-commit": { label: "git", commands: ["\\bgit\\s+(?:-{1,2}[\\w.-]+(?:[ =][^\\s]+)?\\s+){0,4}commit\\b"] },
|
|
10
12
|
};
|
|
11
13
|
/**
|
|
12
14
|
* 合并内置场景与 opencode.json 配置:内置 key 深合并(label/commands/events/tools
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { MetricsDirs } from "../dirs.js";
|
|
2
|
-
import type { UploadConfig } from "./types.js";
|
|
2
|
+
import type { UploadConfig, UploadPackage } from "./types.js";
|
|
3
3
|
export interface UploadManagerOptions {
|
|
4
4
|
config?: UploadConfig;
|
|
5
5
|
/** 实例目录(metricsDir/eventsDir/uploadStagingDir) */
|
|
@@ -23,6 +23,21 @@ export interface UploadManager {
|
|
|
23
23
|
* @param options 配置与目录
|
|
24
24
|
*/
|
|
25
25
|
export declare function createUploadManager(options: UploadManagerOptions): UploadManager | null;
|
|
26
|
+
/**
|
|
27
|
+
* 启动清扫暂存目录顶层:崩溃遗留的完整三件套(tar.gz + json + meta.json)
|
|
28
|
+
* 移入 failed/ 同构目录并入队重传;孤立文件与组装目录直接删除。
|
|
29
|
+
*
|
|
30
|
+
* 两遍扫描:第一遍只处理 .tar.gz(三件套校验 → rescue 或删 tar.gz,并标记
|
|
31
|
+
* 三个文件名已处理)。字母序下 .json/.meta.json 先于 .tar.gz 迭代,若单遍
|
|
32
|
+
* 边迭代边删 .json,会在 tar.gz 校验前破坏三件套——完整崩溃遗留包必然被
|
|
33
|
+
* 误判"不完整"而销毁(曾连续销毁终态包)。第二遍才清理无 tar.gz 对应的
|
|
34
|
+
* 孤儿 .json/meta 与组装残留。
|
|
35
|
+
*
|
|
36
|
+
* @param stagingDir 暂存目录
|
|
37
|
+
* @param rescueEnabled 失败保留开关(false 时完整三件套也直接删除,旧行为)
|
|
38
|
+
* @returns 可补偿重传的包列表(顶层遗留部分)
|
|
39
|
+
*/
|
|
40
|
+
export declare function sweepStagingDirectory(stagingDir: string, rescueEnabled: boolean): UploadPackage[];
|
|
26
41
|
/**
|
|
27
42
|
* 记录会话终态包已成功上云。
|
|
28
43
|
* 快照原件(metrics/<sessionId>.json)会永久保留为本地记录,
|
|
@@ -41,7 +56,10 @@ export interface OrphanSnapshot {
|
|
|
41
56
|
}
|
|
42
57
|
/**
|
|
43
58
|
* 扫描目录选出孤儿快照候选:mtime 静默 [idleMs, maxAgeMs] 且不在活跃集的快照。
|
|
44
|
-
*
|
|
59
|
+
* 快照原件永久保留为本地记录,"已传标记"是防重复上传的依据。
|
|
60
|
+
* 时效判断:标记只证明"标记时刻之前的快照已上云"——会话在中途终态触发
|
|
61
|
+
* (task 完成/session.deleted)上传后仍可能继续运行并更新快照,因此快照
|
|
62
|
+
* mtime 晚于标记时间(超出 60s 缓冲)仍视为孤儿,补传最终态。
|
|
45
63
|
*
|
|
46
64
|
* @param metricsDir 快照目录
|
|
47
65
|
* @param idleMs 静默下限(毫秒)
|