opencode-metrics-plugin 0.3.4 → 0.3.5
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 +5 -0
- package/dist/metrics/engine/engine.js +6 -1
- package/dist/metrics/engine/handlers.js +11 -1
- package/dist/metrics/engine/state.d.ts +45 -0
- package/dist/metrics/snapshot/flush.d.ts +10 -1
- package/dist/metrics/snapshot/flush.js +76 -31
- package/dist/metrics/snapshot/merge.d.ts +2 -2
- package/dist/metrics/snapshot/merge.js +271 -40
- package/dist/metrics/types.d.ts +4 -0
- package/dist/metrics/upload/agcUploader.d.ts +6 -0
- package/dist/metrics/upload/agcUploader.js +10 -0
- package/dist/metrics/upload/types.d.ts +6 -0
- package/dist/metrics/upload/uploadManager.js +24 -2
- package/dist/metrics/upload/uploadQueue.d.ts +2 -0
- package/dist/metrics/upload/uploadQueue.js +3 -0
- package/dist/plugin.js +3 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.5(2026-09-15)
|
|
4
|
+
|
|
5
|
+
- 修复「重启 + resume 续跑」系列数据损坏:steps 重复乱序、planning 清零、tokens/工具统计丢段、agent/model 归因丢失;累加字段改差量并入,连发消息即切轮、退出兜底补轮。
|
|
6
|
+
- 上传修复:退出 opencode(含 Ctrl+C)时当场完成上传;没传完的下次启动自动补传;不再丢失"上传完成"日志。
|
|
7
|
+
|
|
3
8
|
## 0.3.4(2026-09-14)
|
|
4
9
|
|
|
5
10
|
- 修复 0.3.3 发布事故:package.json 误带 UTF-8 BOM,npm 可安装但 opencode 解析插件清单失败并**静默跳过加载**——所有 0.3.3 用户完全不采集、不上传、日志无任何痕迹。升级本版后必须删除插件缓存并重启 opencode(Windows `%USERPROFILE%\.cache\opencode\packages\opencode-metrics-plugin*`,macOS `~/Library/Caches/opencode/packages/` 同名目录);事故期间未采集的会话无法补录,装好后孤儿补传会自动补传 7 天内未上终态的历史快照。
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { TRACKED_EVENT_TYPES } from "../types.js";
|
|
2
2
|
import { createSessionMetrics } from "./state.js";
|
|
3
3
|
import { handlePartUpdated, handleSessionUpdated, handleMessageUpdated, handleSubAgentParts } from "./handlers.js";
|
|
4
|
-
import { handleSessionIdle, flushMetrics } from "../snapshot/flush.js";
|
|
4
|
+
import { handleSessionIdle, flushMetrics, buildRoundSnapshot } from "../snapshot/flush.js";
|
|
5
5
|
import { clearStepContentCache } from "../snapshot/steps.js";
|
|
6
6
|
import { mergeChildMetrics } from "../snapshot/merge.js";
|
|
7
7
|
import { resolveDirs } from "../dirs.js";
|
|
@@ -90,6 +90,11 @@ export function createMetricsEngine(opts = {}) {
|
|
|
90
90
|
* @param now 结束时刻(ms)
|
|
91
91
|
*/
|
|
92
92
|
function finalizeAndFlush(sessionId, state, now) {
|
|
93
|
+
// 兜底补轮:退出/删除前最后一次交互往往等不到 session.idle(约 1 分钟静止判定),
|
|
94
|
+
// 此处把未闭合的非空轮收掉(空轮返回 null,已 idle 过的轮累计已重置、同样为 null,天然防重)
|
|
95
|
+
const pendingRound = buildRoundSnapshot(state, now);
|
|
96
|
+
if (pendingRound)
|
|
97
|
+
state.rounds.push(pendingRound);
|
|
93
98
|
if (state.currentStage) {
|
|
94
99
|
state.currentStage.endTime = now;
|
|
95
100
|
state.currentStage.tokens = { ...state._stageTokens };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createTokenUsage } from "../types.js";
|
|
2
2
|
import { parseHvigorwOutput } from "../analysis/hvigor.js";
|
|
3
|
+
import { buildRoundSnapshot, resetRoundState } from "../snapshot/flush.js";
|
|
3
4
|
// ─── Input Simplification ────────────────────────────────────────────────────
|
|
4
5
|
function simplifyInput(raw) {
|
|
5
6
|
if (!raw)
|
|
@@ -472,8 +473,17 @@ function handleMessageUpdated(state, props) {
|
|
|
472
473
|
// Track user message IDs so we can capture their text from message.part.updated
|
|
473
474
|
if (info.role === "user") {
|
|
474
475
|
const msgId = info.id;
|
|
475
|
-
if (msgId)
|
|
476
|
+
if (msgId && !state._userMessageIds.has(msgId)) {
|
|
477
|
+
// 新用户消息 = 天然轮边界:先收掉当前非空轮(不再等 idle 静止判定),
|
|
478
|
+
// 快速连发多条不再被合并进同一轮;resume 重放期间轮累计为空,
|
|
479
|
+
// buildRoundSnapshot 返回 null 无副作用,重放结束后首条真实消息正常开新轮
|
|
480
|
+
const round = buildRoundSnapshot(state, Date.now());
|
|
481
|
+
if (round) {
|
|
482
|
+
state.rounds.push(round);
|
|
483
|
+
resetRoundState(state, Date.now());
|
|
484
|
+
}
|
|
476
485
|
state._userMessageIds.add(msgId);
|
|
486
|
+
}
|
|
477
487
|
state._roundIdleProcessed = false;
|
|
478
488
|
return;
|
|
479
489
|
}
|
|
@@ -22,6 +22,51 @@ export interface FlushBaseline {
|
|
|
22
22
|
taskCount: number;
|
|
23
23
|
}>;
|
|
24
24
|
fixCycleCount: number;
|
|
25
|
+
/** 顶层 tokens 快照(差量 = 下次 fresh − 本值;重启后 baseline 为空即全量并入) */
|
|
26
|
+
tokens: {
|
|
27
|
+
input: number;
|
|
28
|
+
output: number;
|
|
29
|
+
reasoning: number;
|
|
30
|
+
cacheRead: number;
|
|
31
|
+
cacheWrite: number;
|
|
32
|
+
total: number;
|
|
33
|
+
};
|
|
34
|
+
/** 工具计数快照(distribution 存每 tool 总量,用于算 calls/errors/totalDuration 差量) */
|
|
35
|
+
tools: {
|
|
36
|
+
totalCalls: number;
|
|
37
|
+
invalidCalls: number;
|
|
38
|
+
distribution: Record<string, {
|
|
39
|
+
calls: number;
|
|
40
|
+
errors: number;
|
|
41
|
+
totalDuration: number;
|
|
42
|
+
maxDuration: number;
|
|
43
|
+
}>;
|
|
44
|
+
};
|
|
45
|
+
/** 压缩次数快照 */
|
|
46
|
+
compactions: number;
|
|
47
|
+
/** 回复总字符数快照 */
|
|
48
|
+
responseLength: number;
|
|
49
|
+
/** anomaly.events 数组长度快照(events 只追加) */
|
|
50
|
+
anomalyEventsTotal: number;
|
|
51
|
+
/** stages 数组长度快照(stages 只追加) */
|
|
52
|
+
stagesTotal: number;
|
|
53
|
+
/** header 归因切换计数快照(差量 = fresh − 本值) */
|
|
54
|
+
agentSwitches: number;
|
|
55
|
+
modelSwitches: number;
|
|
56
|
+
/** 各 agent 累计使用时长快照(毫秒) */
|
|
57
|
+
agentUsage: Record<string, number>;
|
|
58
|
+
/** 各模型 token 分布快照(state 内存态全量) */
|
|
59
|
+
modelTokenDistribution: Record<string, {
|
|
60
|
+
input: number;
|
|
61
|
+
output: number;
|
|
62
|
+
reasoning: number;
|
|
63
|
+
cacheRead: number;
|
|
64
|
+
cacheWrite: number;
|
|
65
|
+
total: number;
|
|
66
|
+
}>;
|
|
67
|
+
/** 编译修复次数与累计错误数快照(codeStats 累计型) */
|
|
68
|
+
fixCompileCount: number;
|
|
69
|
+
totalCompileErrors: number;
|
|
25
70
|
}
|
|
26
71
|
export interface SessionMetricsState {
|
|
27
72
|
sessionId: string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { MetricsDirs } from "../dirs.js";
|
|
2
2
|
import type { SessionMetricsState } from "../engine/state.js";
|
|
3
|
-
import type { MetricsOutput } from "../types.js";
|
|
3
|
+
import type { RoundSnapshot, MetricsOutput } from "../types.js";
|
|
4
4
|
/**
|
|
5
5
|
* 把会话当前状态落盘为快照文件(读旧文件 → 合并 → 原子覆盖写)。
|
|
6
6
|
* 可重复调用,不会造成数据重复累计。
|
|
@@ -19,5 +19,14 @@ declare function flushMetrics(sessionId: string, state: SessionMetricsState, now
|
|
|
19
19
|
* @param sessionId 会话 id
|
|
20
20
|
* @param dirs 输出目录;缺省用进程级默认目录
|
|
21
21
|
*/
|
|
22
|
+
/**
|
|
23
|
+
* 从当前轮累计态构建轮快照;空轮(无 token 且无工具调用)返回 null(天然防重)。
|
|
24
|
+
*
|
|
25
|
+
* @param state 会话状态(读取轮累计字段,不修改)
|
|
26
|
+
* @param now 结算时刻(ms)
|
|
27
|
+
*/
|
|
28
|
+
export declare function buildRoundSnapshot(state: SessionMetricsState, now: number): RoundSnapshot | null;
|
|
29
|
+
/** 重置轮累计态,开始新轮(不改动 stage 相关状态)。 */
|
|
30
|
+
export declare function resetRoundState(state: SessionMetricsState, now: number): void;
|
|
22
31
|
declare function handleSessionIdle(state: SessionMetricsState, sessionId: string, dirs?: Partial<MetricsDirs>): MetricsOutput | undefined;
|
|
23
32
|
export { flushMetrics, handleSessionIdle };
|
|
@@ -219,21 +219,25 @@ function flushMetrics(sessionId, state, now, dirs) {
|
|
|
219
219
|
}
|
|
220
220
|
const output = {
|
|
221
221
|
sessionId,
|
|
222
|
+
procStartTime: state.startTime,
|
|
222
223
|
startTime: state.startTime,
|
|
223
224
|
endTime,
|
|
224
225
|
duration,
|
|
225
226
|
systemPrompts,
|
|
226
227
|
rounds: state.rounds,
|
|
227
228
|
tokens: (() => {
|
|
228
|
-
|
|
229
|
+
// step-finish 增量口径优先(真实消耗);消息口径(每轮含全上下文)仅增量缺失时兜底。
|
|
230
|
+
// 旧实现取两者 max 会把消息口径的虚高值(resume 重放历史消息时尤为严重)混进顶层统计。
|
|
231
|
+
const preferState = state.tokens.total > 0;
|
|
232
|
+
const base = preferState ? state.tokens : parentStepTokens;
|
|
233
|
+
const totalTokens = base.total + subAgentTokens.total;
|
|
229
234
|
const totalSteps = parentStepCount + subAgentStepCount;
|
|
230
235
|
return {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
cacheWrite: Math.max(state.tokens.cacheWrite, parentStepTokens.cacheWrite) + subAgentTokens.cacheWrite,
|
|
236
|
+
input: base.input + subAgentTokens.input,
|
|
237
|
+
output: base.output + subAgentTokens.output,
|
|
238
|
+
reasoning: base.reasoning + subAgentTokens.reasoning,
|
|
239
|
+
cacheRead: base.cacheRead + subAgentTokens.cacheRead,
|
|
240
|
+
cacheWrite: base.cacheWrite + subAgentTokens.cacheWrite,
|
|
237
241
|
total: totalTokens,
|
|
238
242
|
cacheHitRate,
|
|
239
243
|
avgTokensPerStep: totalSteps > 0 ? Math.round(totalTokens / totalSteps) : 0,
|
|
@@ -347,6 +351,35 @@ function flushMetrics(sessionId, state, now, dirs) {
|
|
|
347
351
|
warningEntriesTotal: output.codeStats.warnings.entries.length,
|
|
348
352
|
moduleTimings: Object.fromEntries(output.codeStats.moduleTimings.map(m => [m.module, { totalDuration: m.totalDurationMs, taskCount: m.taskCount }])),
|
|
349
353
|
fixCycleCount: output.codeStats.fixCycles.cycles.length,
|
|
354
|
+
tokens: {
|
|
355
|
+
input: output.tokens.input,
|
|
356
|
+
output: output.tokens.output,
|
|
357
|
+
reasoning: output.tokens.reasoning,
|
|
358
|
+
cacheRead: output.tokens.cacheRead,
|
|
359
|
+
cacheWrite: output.tokens.cacheWrite,
|
|
360
|
+
total: output.tokens.total,
|
|
361
|
+
},
|
|
362
|
+
tools: {
|
|
363
|
+
totalCalls: output.tools.totalCalls,
|
|
364
|
+
invalidCalls: output.tools.invalidCalls,
|
|
365
|
+
distribution: Object.fromEntries(Object.entries(output.tools.distribution).map(([tool, d]) => [
|
|
366
|
+
tool,
|
|
367
|
+
{ calls: d.calls, errors: d.errors, totalDuration: d.avgDuration * d.calls, maxDuration: d.maxDuration },
|
|
368
|
+
])),
|
|
369
|
+
},
|
|
370
|
+
compactions: output.compactions,
|
|
371
|
+
responseLength: output.responseLength,
|
|
372
|
+
anomalyEventsTotal: output.anomaly.events.length,
|
|
373
|
+
stagesTotal: output.stages.length,
|
|
374
|
+
agentSwitches: output.header.agentSwitches,
|
|
375
|
+
modelSwitches: output.header.modelSwitches,
|
|
376
|
+
agentUsage: { ...output.header.agentUsage },
|
|
377
|
+
modelTokenDistribution: Object.fromEntries(Object.entries(output.header.modelTokenDistribution ?? {}).map(([model, dist]) => [
|
|
378
|
+
model,
|
|
379
|
+
{ input: dist.input, output: dist.output, reasoning: dist.reasoning, cacheRead: dist.cacheRead, cacheWrite: dist.cacheWrite, total: dist.total },
|
|
380
|
+
])),
|
|
381
|
+
fixCompileCount: output.codeStats.fixCompileCount,
|
|
382
|
+
totalCompileErrors: output.codeStats.totalCompileErrors,
|
|
350
383
|
};
|
|
351
384
|
}
|
|
352
385
|
catch (err) {
|
|
@@ -361,33 +394,32 @@ function flushMetrics(sessionId, state, now, dirs) {
|
|
|
361
394
|
* @param sessionId 会话 id
|
|
362
395
|
* @param dirs 输出目录;缺省用进程级默认目录
|
|
363
396
|
*/
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
397
|
+
/**
|
|
398
|
+
* 从当前轮累计态构建轮快照;空轮(无 token 且无工具调用)返回 null(天然防重)。
|
|
399
|
+
*
|
|
400
|
+
* @param state 会话状态(读取轮累计字段,不修改)
|
|
401
|
+
* @param now 结算时刻(ms)
|
|
402
|
+
*/
|
|
403
|
+
export function buildRoundSnapshot(state, now) {
|
|
404
|
+
const isEmptyRound = state._roundTokens.total === 0 && state._roundToolCalls === 0;
|
|
405
|
+
if (isEmptyRound)
|
|
406
|
+
return null;
|
|
368
407
|
const firstTokenLatency = state._hasTextPart && state._firstEventTime > 0
|
|
369
408
|
? state._firstTextTime - state._firstEventTime
|
|
370
409
|
: 0;
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
errors: state._roundErrors,
|
|
385
|
-
userMessage: state._pendingUserMessage.length > 0 ? [...state._pendingUserMessage] : undefined,
|
|
386
|
-
};
|
|
387
|
-
state.rounds.push(round);
|
|
388
|
-
}
|
|
389
|
-
state._roundIdleProcessed = true;
|
|
390
|
-
// Reset per-round state
|
|
410
|
+
return {
|
|
411
|
+
startTime: state._roundStartTime,
|
|
412
|
+
roundIndex: state.rounds.length,
|
|
413
|
+
duration: now - state._roundStartTime,
|
|
414
|
+
firstTokenLatency,
|
|
415
|
+
tokens: { ...state._roundTokens },
|
|
416
|
+
toolCalls: state._roundToolCalls,
|
|
417
|
+
errors: state._roundErrors,
|
|
418
|
+
userMessage: state._pendingUserMessage.length > 0 ? [...state._pendingUserMessage] : undefined,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
/** 重置轮累计态,开始新轮(不改动 stage 相关状态)。 */
|
|
422
|
+
export function resetRoundState(state, now) {
|
|
391
423
|
state._roundStartTime = now;
|
|
392
424
|
state._firstEventTime = 0;
|
|
393
425
|
state._firstTextTime = 0;
|
|
@@ -398,6 +430,19 @@ function handleSessionIdle(state, sessionId, dirs) {
|
|
|
398
430
|
state._roundErrors = 0;
|
|
399
431
|
state._pendingUserMessage = [];
|
|
400
432
|
state._roundFirstBuildTracked = false;
|
|
433
|
+
}
|
|
434
|
+
function handleSessionIdle(state, sessionId, dirs) {
|
|
435
|
+
const now = Date.now();
|
|
436
|
+
// Skip duplicate session.idle — round was already created
|
|
437
|
+
if (state._roundIdleProcessed) {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const round = buildRoundSnapshot(state, now);
|
|
441
|
+
if (round)
|
|
442
|
+
state.rounds.push(round);
|
|
443
|
+
state._roundIdleProcessed = true;
|
|
444
|
+
// Reset per-round state
|
|
445
|
+
resetRoundState(state, now);
|
|
401
446
|
// Finalize current stage
|
|
402
447
|
if (state.currentStage) {
|
|
403
448
|
state.currentStage.endTime = now;
|
|
@@ -6,11 +6,11 @@ declare function mergeChildMetrics(parent: SessionMetricsState, child: SessionMe
|
|
|
6
6
|
title: string;
|
|
7
7
|
}): void;
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
9
|
+
* 合并磁盘旧快照与内存新快照:覆盖类字段取新值,累加类字段按差量并入。
|
|
10
10
|
*
|
|
11
11
|
* @param existing 磁盘上的旧快照;null 时直接返回 fresh
|
|
12
12
|
* @param fresh 由当前内存态构建的新快照
|
|
13
|
-
* @param baseline 上次 flush
|
|
13
|
+
* @param baseline 上次 flush 时累加字段的累计快照,用于计算本次差量;null 时累加字段全量并入
|
|
14
14
|
* @returns 合并后的快照,可安全反复写盘
|
|
15
15
|
*/
|
|
16
16
|
export declare function mergeMetricsOutput(existing: MetricsOutput | null, fresh: MetricsOutput, baseline?: FlushBaseline | null): MetricsOutput;
|
|
@@ -124,11 +124,47 @@ function mergeChildMetrics(parent, child, meta) {
|
|
|
124
124
|
parent.planningCalls.push(...child.planningCalls);
|
|
125
125
|
}
|
|
126
126
|
/**
|
|
127
|
-
*
|
|
127
|
+
* 基于规划调用列表重算 planning 汇总字段(口径与 flush 构建时一致:
|
|
128
|
+
* 任务统计取最后一轮 todos,时长为全部轮次累加)。用于快照合并——
|
|
129
|
+
* 合并方无法信任任何一方的汇总(fresh 可能是重启后的空态),只能从合并后的 calls 重新推导。
|
|
130
|
+
*/
|
|
131
|
+
function summarizePlanningCalls(calls) {
|
|
132
|
+
if (calls.length === 0) {
|
|
133
|
+
return {
|
|
134
|
+
planningRounds: 0, totalPlanningDuration: 0, avgPlanningDuration: 0,
|
|
135
|
+
totalTasks: 0, completedTasks: 0, inProgressTasks: 0, pendingTasks: 0,
|
|
136
|
+
completionRate: 0, priorityDistribution: {}, calls: [],
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const totalDuration = calls.reduce((sum, c) => sum + c.durationMs, 0);
|
|
140
|
+
const lastTodos = calls[calls.length - 1].todos;
|
|
141
|
+
const totalTasks = lastTodos.length;
|
|
142
|
+
const completedTasks = lastTodos.filter(t => t.status === "completed").length;
|
|
143
|
+
const inProgressTasks = lastTodos.filter(t => t.status === "in_progress").length;
|
|
144
|
+
const pendingTasks = lastTodos.filter(t => t.status === "pending").length;
|
|
145
|
+
const priorityDistribution = {};
|
|
146
|
+
for (const t of lastTodos) {
|
|
147
|
+
priorityDistribution[t.priority] = (priorityDistribution[t.priority] || 0) + 1;
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
planningRounds: calls.length,
|
|
151
|
+
totalPlanningDuration: totalDuration,
|
|
152
|
+
avgPlanningDuration: Math.round(totalDuration / calls.length),
|
|
153
|
+
totalTasks,
|
|
154
|
+
completedTasks,
|
|
155
|
+
inProgressTasks,
|
|
156
|
+
pendingTasks,
|
|
157
|
+
completionRate: totalTasks > 0 ? completedTasks / totalTasks : 0,
|
|
158
|
+
priorityDistribution,
|
|
159
|
+
calls,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* 合并磁盘旧快照与内存新快照:覆盖类字段取新值,累加类字段按差量并入。
|
|
128
164
|
*
|
|
129
165
|
* @param existing 磁盘上的旧快照;null 时直接返回 fresh
|
|
130
166
|
* @param fresh 由当前内存态构建的新快照
|
|
131
|
-
* @param baseline 上次 flush
|
|
167
|
+
* @param baseline 上次 flush 时累加字段的累计快照,用于计算本次差量;null 时累加字段全量并入
|
|
132
168
|
* @returns 合并后的快照,可安全反复写盘
|
|
133
169
|
*/
|
|
134
170
|
export function mergeMetricsOutput(existing, fresh, baseline) {
|
|
@@ -138,27 +174,58 @@ export function mergeMetricsOutput(existing, fresh, baseline) {
|
|
|
138
174
|
const startTime = Math.min(existing.startTime, fresh.startTime);
|
|
139
175
|
// duration: fresh.endTime - min startTime
|
|
140
176
|
const duration = fresh.endTime - startTime;
|
|
141
|
-
// Rounds
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
177
|
+
// Rounds:重启后内存态的 roundIndex 从 0 重编,与磁盘 existing 语义错位。
|
|
178
|
+
// 用双方首轮的 startTime 判断进程关系:
|
|
179
|
+
// - 首轮起点一致 → 同进程(fresh 是全量态),fresh 即权威;
|
|
180
|
+
// - 不一致 / existing 无 startTime(旧版快照,插件升级必重启)→ 重启,existing 全保留
|
|
181
|
+
// + fresh 尾部追加重编(不丢重启后的真实新轮);
|
|
182
|
+
// - fresh 无 startTime(旧版代码产物)→ 回退旧策略 existing wins by index。
|
|
183
|
+
const rounds = (() => {
|
|
184
|
+
const f0 = fresh.rounds[0]?.startTime;
|
|
185
|
+
const e0 = existing.rounds[0]?.startTime;
|
|
186
|
+
if (f0 !== undefined) {
|
|
187
|
+
if (e0 !== undefined && f0 === e0) {
|
|
188
|
+
// 同进程:fresh 是全量态(首轮起点一致),直接采用并按其自身顺序编号
|
|
189
|
+
return fresh.rounds.map((r, i) => ({ ...r, roundIndex: i }));
|
|
190
|
+
}
|
|
191
|
+
// 重启(含 existing 为旧版快照无 startTime:插件升级必然重启进程,fresh 只含新段)
|
|
192
|
+
// existing 全保留 + fresh 轮追加尾部并重编 index,不再"撞 index 丢 fresh";
|
|
193
|
+
// 追加前按轮特征去重——同轮双份(idle 建轮后紧接 checkpoint 再 flush 一次)只保留一份
|
|
194
|
+
const roundKey = (r) => `${r.startTime ?? 0}|${r.duration}|${(r.userMessage ?? []).join("\u0000")}|${r.toolCalls}|${r.tokens.total}`;
|
|
195
|
+
const seenRounds = new Set(existing.rounds.map(roundKey));
|
|
196
|
+
return [
|
|
197
|
+
...existing.rounds,
|
|
198
|
+
...fresh.rounds
|
|
199
|
+
.filter(r => !seenRounds.has(roundKey(r)))
|
|
200
|
+
.map((r, i) => ({ ...r, roundIndex: existing.rounds.length + i })),
|
|
201
|
+
];
|
|
202
|
+
}
|
|
203
|
+
// fresh 由旧版代码产生(无 startTime):回退旧策略 existing wins by index
|
|
204
|
+
const existingRoundIndices = new Set(existing.rounds.map(r => r.roundIndex));
|
|
205
|
+
return [
|
|
206
|
+
...existing.rounds,
|
|
207
|
+
...fresh.rounds.filter(r => !existingRoundIndices.has(r.roundIndex)),
|
|
208
|
+
].sort((a, b) => a.roundIndex - b.roundIndex);
|
|
209
|
+
})();
|
|
210
|
+
// Steps:进程重启后 orderCounter 从 0 重编,fresh 的 index 与磁盘 existing 撞车,
|
|
211
|
+
// 且时间上更晚的新步骤被排到列表头部;resume 重灌轮的时间戳全新,index 与时间戳都无法跨进程关联。
|
|
212
|
+
// 统一策略:按 startTime:endTime 去重(防同进程重放,fresh wins)后,
|
|
213
|
+
// 全部条目按 startTime 升序排序并重编 index —— 时间是跨进程唯一可信的顺序,
|
|
214
|
+
// index 撞车与"新步骤跳到头部"同时消除,不丢弃任何真实轮次。
|
|
151
215
|
const stepSeen = new Set();
|
|
152
|
-
const
|
|
216
|
+
const stepMerged = [];
|
|
153
217
|
for (const s of fresh.steps) {
|
|
154
218
|
const k = `${s.startTime}:${s.endTime}`;
|
|
155
219
|
stepSeen.add(k);
|
|
156
|
-
|
|
220
|
+
stepMerged.push(s);
|
|
157
221
|
}
|
|
158
222
|
for (const s of existing.steps) {
|
|
159
223
|
if (!stepSeen.has(`${s.startTime}:${s.endTime}`))
|
|
160
|
-
|
|
224
|
+
stepMerged.push(s);
|
|
161
225
|
}
|
|
226
|
+
const steps = stepMerged
|
|
227
|
+
.sort((a, b) => a.startTime - b.startTime)
|
|
228
|
+
.map((s, i) => ({ ...s, index: i }));
|
|
162
229
|
// Subagents: append with dedup by sessionId (fresh wins)
|
|
163
230
|
const subagentMap = new Map();
|
|
164
231
|
for (const s of existing.subagents)
|
|
@@ -181,10 +248,11 @@ export function mergeMetricsOutput(existing, fresh, baseline) {
|
|
|
181
248
|
mergedCalls.push(c);
|
|
182
249
|
}
|
|
183
250
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
251
|
+
// 汇总字段基于合并后的 calls 重算,不能取 fresh 的汇总:
|
|
252
|
+
// 进程重启后内存态 planningCalls 为空(fresh 汇总全零),但磁盘上已有历史规划轮次,
|
|
253
|
+
// 直接展开 fresh 会把汇总清零,造成"calls 有数据、汇总全 0"的不一致。
|
|
254
|
+
mergedCalls.sort((a, b) => a.startTime - b.startTime);
|
|
255
|
+
const planning = summarizePlanningCalls(mergedCalls);
|
|
188
256
|
// Skills: append with dedup by skillName (fresh wins)
|
|
189
257
|
const skillMap = new Map();
|
|
190
258
|
for (const s of existing.skills)
|
|
@@ -272,39 +340,202 @@ export function mergeMetricsOutput(existing, fresh, baseline) {
|
|
|
272
340
|
: []),
|
|
273
341
|
],
|
|
274
342
|
};
|
|
275
|
-
// codeStats.firstBuildPerRound: fresh accumulates all rounds, use fresh
|
|
276
|
-
const firstBuildPerRound = fresh.codeStats.firstBuildPerRound;
|
|
277
343
|
// systemPrompts: union of keys, fresh values win
|
|
278
344
|
const systemPrompts = { ...existing.systemPrompts };
|
|
279
345
|
for (const [key, val] of Object.entries(fresh.systemPrompts)) {
|
|
280
346
|
systemPrompts[key] = val;
|
|
281
347
|
}
|
|
348
|
+
// 顶层统计字段的差量并入:fresh 是当前内存态累计,existing 是上次落盘的合并结果。
|
|
349
|
+
// 差量 = fresh − baseline(baseline 为上次 flush 时的 fresh 快照):
|
|
350
|
+
// - 同进程反复 flush:差量为本次新增量,existing + 差量 = 全量,不双计;
|
|
351
|
+
// - 重启后 baseline 为空:差量 = fresh 全量,天然跨进程相加;
|
|
352
|
+
// - resume 渐进重放期间:每次只并入重放新增量,不会用中间态覆盖已并入的旧段
|
|
353
|
+
// (这是进程标记方案的缺陷:重放未完成时 fresh 非全量,fresh-wins 会冲掉旧段)。
|
|
354
|
+
// 派生值(命中率/均步 token/平均耗时/成功率)用合并后基数重算。
|
|
355
|
+
const tokensInc = {
|
|
356
|
+
input: Math.max(0, fresh.tokens.input - (baseline?.tokens.input ?? 0)),
|
|
357
|
+
output: Math.max(0, fresh.tokens.output - (baseline?.tokens.output ?? 0)),
|
|
358
|
+
reasoning: Math.max(0, fresh.tokens.reasoning - (baseline?.tokens.reasoning ?? 0)),
|
|
359
|
+
cacheRead: Math.max(0, fresh.tokens.cacheRead - (baseline?.tokens.cacheRead ?? 0)),
|
|
360
|
+
cacheWrite: Math.max(0, fresh.tokens.cacheWrite - (baseline?.tokens.cacheWrite ?? 0)),
|
|
361
|
+
};
|
|
362
|
+
const tokensTotalRaw = existing.tokens.input + tokensInc.input
|
|
363
|
+
+ existing.tokens.output + tokensInc.output
|
|
364
|
+
+ existing.tokens.reasoning + tokensInc.reasoning
|
|
365
|
+
+ existing.tokens.cacheRead + tokensInc.cacheRead
|
|
366
|
+
+ existing.tokens.cacheWrite + tokensInc.cacheWrite;
|
|
367
|
+
const tokens = {
|
|
368
|
+
input: existing.tokens.input + tokensInc.input,
|
|
369
|
+
output: existing.tokens.output + tokensInc.output,
|
|
370
|
+
reasoning: existing.tokens.reasoning + tokensInc.reasoning,
|
|
371
|
+
cacheRead: existing.tokens.cacheRead + tokensInc.cacheRead,
|
|
372
|
+
cacheWrite: existing.tokens.cacheWrite + tokensInc.cacheWrite,
|
|
373
|
+
total: tokensTotalRaw,
|
|
374
|
+
cacheHitRate: tokensTotalRaw > 0
|
|
375
|
+
? (existing.tokens.cacheRead + tokensInc.cacheRead) / Math.max(1, existing.tokens.input + tokensInc.input + existing.tokens.cacheRead + tokensInc.cacheRead)
|
|
376
|
+
: 0,
|
|
377
|
+
avgTokensPerStep: steps.length > 0 ? Math.round(tokensTotalRaw / steps.length) : 0,
|
|
378
|
+
};
|
|
379
|
+
const toolsIncTotal = Math.max(0, fresh.tools.totalCalls - (baseline?.tools.totalCalls ?? 0));
|
|
380
|
+
const toolsIncInvalid = Math.max(0, fresh.tools.invalidCalls - (baseline?.tools.invalidCalls ?? 0));
|
|
381
|
+
const distribution = {};
|
|
382
|
+
for (const [tool, d] of Object.entries(existing.tools.distribution))
|
|
383
|
+
distribution[tool] = { ...d };
|
|
384
|
+
let incErrors = 0;
|
|
385
|
+
for (const [tool, d] of Object.entries(fresh.tools.distribution)) {
|
|
386
|
+
const base = baseline?.tools.distribution[tool];
|
|
387
|
+
const incCalls = Math.max(0, d.calls - (base?.calls ?? 0));
|
|
388
|
+
const incErrors_ = Math.max(0, d.errors - (base?.errors ?? 0));
|
|
389
|
+
const incDuration = Math.max(0, d.avgDuration * d.calls - (base?.totalDuration ?? 0));
|
|
390
|
+
if (incCalls === 0 && incErrors_ === 0 && incDuration === 0)
|
|
391
|
+
continue;
|
|
392
|
+
incErrors += incErrors_;
|
|
393
|
+
const prev = distribution[tool];
|
|
394
|
+
if (prev) {
|
|
395
|
+
const calls = prev.calls + incCalls;
|
|
396
|
+
const totalDuration = prev.avgDuration * prev.calls + incDuration;
|
|
397
|
+
distribution[tool] = {
|
|
398
|
+
calls,
|
|
399
|
+
errors: prev.errors + incErrors_,
|
|
400
|
+
avgDuration: calls > 0 ? totalDuration / calls : 0,
|
|
401
|
+
maxDuration: Math.max(prev.maxDuration, d.maxDuration),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
distribution[tool] = {
|
|
406
|
+
calls: incCalls,
|
|
407
|
+
errors: incErrors_,
|
|
408
|
+
avgDuration: incCalls > 0 ? incDuration / incCalls : 0,
|
|
409
|
+
maxDuration: d.maxDuration,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
const mergedTotalCalls = existing.tools.totalCalls + toolsIncTotal;
|
|
414
|
+
const mergedInvalidCalls = existing.tools.invalidCalls + toolsIncInvalid;
|
|
415
|
+
const tools = {
|
|
416
|
+
totalCalls: mergedTotalCalls,
|
|
417
|
+
invalidCalls: mergedInvalidCalls,
|
|
418
|
+
successRate: (() => {
|
|
419
|
+
if (mergedTotalCalls <= 0)
|
|
420
|
+
return 0;
|
|
421
|
+
const incValid = Math.max(0, toolsIncTotal - toolsIncInvalid - incErrors);
|
|
422
|
+
const existingValid = existing.tools.successRate * existing.tools.totalCalls;
|
|
423
|
+
return (existingValid + incValid) / mergedTotalCalls;
|
|
424
|
+
})(),
|
|
425
|
+
distribution,
|
|
426
|
+
slowestCall: fresh.tools.slowestCall.duration >= existing.tools.slowestCall.duration
|
|
427
|
+
? fresh.tools.slowestCall
|
|
428
|
+
: existing.tools.slowestCall,
|
|
429
|
+
};
|
|
430
|
+
const compactions = existing.compactions + Math.max(0, fresh.compactions - (baseline?.compactions ?? 0));
|
|
431
|
+
// anomaly.events 是事件类型名的并集(数组天然去重),直接合并
|
|
432
|
+
const anomaly = {
|
|
433
|
+
triggered: existing.anomaly.triggered || fresh.anomaly.triggered,
|
|
434
|
+
events: [...new Set([...existing.anomaly.events, ...fresh.anomaly.events])],
|
|
435
|
+
};
|
|
436
|
+
// stages 只追加:并入基线长度之后的新增尾部
|
|
437
|
+
const stagesBaseline = baseline?.stagesTotal ?? 0;
|
|
438
|
+
const stages = fresh.stages.length > stagesBaseline
|
|
439
|
+
? [...existing.stages, ...fresh.stages.slice(stagesBaseline)]
|
|
440
|
+
: [...existing.stages];
|
|
441
|
+
const responseLength = existing.responseLength + Math.max(0, fresh.responseLength - (baseline?.responseLength ?? 0));
|
|
442
|
+
// Header 字段级合并:resume 进程的 fresh 是"非全量态"——未收到 session.updated 前
|
|
443
|
+
// agent/model/workingDirectory 为空("不知道",不是"变成空"),startTime 是进程时间
|
|
444
|
+
// 而非会话时间。规则:非空才覆盖(真实状态更新)、startTime 取更早(会话真实起点)、
|
|
445
|
+
// 计数与分布按差量并入(跨进程累计,同进程反复 flush 不双计)。
|
|
446
|
+
const header = (() => {
|
|
447
|
+
const agentUsage = { ...existing.header.agentUsage };
|
|
448
|
+
for (const [agent, ms] of Object.entries(fresh.header.agentUsage ?? {})) {
|
|
449
|
+
const inc = Math.max(0, ms - (baseline?.agentUsage[agent] ?? 0));
|
|
450
|
+
agentUsage[agent] = (agentUsage[agent] ?? 0) + inc;
|
|
451
|
+
}
|
|
452
|
+
const modelTokenDistribution = {};
|
|
453
|
+
for (const [model, dist] of Object.entries(existing.header.modelTokenDistribution ?? {})) {
|
|
454
|
+
modelTokenDistribution[model] = { ...dist };
|
|
455
|
+
}
|
|
456
|
+
for (const [model, dist] of Object.entries(fresh.header.modelTokenDistribution ?? {})) {
|
|
457
|
+
const base = baseline?.modelTokenDistribution[model];
|
|
458
|
+
const inc = {
|
|
459
|
+
input: Math.max(0, dist.input - (base?.input ?? 0)),
|
|
460
|
+
output: Math.max(0, dist.output - (base?.output ?? 0)),
|
|
461
|
+
reasoning: Math.max(0, dist.reasoning - (base?.reasoning ?? 0)),
|
|
462
|
+
cacheRead: Math.max(0, dist.cacheRead - (base?.cacheRead ?? 0)),
|
|
463
|
+
cacheWrite: Math.max(0, dist.cacheWrite - (base?.cacheWrite ?? 0)),
|
|
464
|
+
total: 0,
|
|
465
|
+
};
|
|
466
|
+
inc.total = inc.input + inc.output + inc.reasoning + inc.cacheRead + inc.cacheWrite;
|
|
467
|
+
const prev = modelTokenDistribution[model];
|
|
468
|
+
if (prev) {
|
|
469
|
+
prev.input += inc.input;
|
|
470
|
+
prev.output += inc.output;
|
|
471
|
+
prev.reasoning += inc.reasoning;
|
|
472
|
+
prev.cacheRead += inc.cacheRead;
|
|
473
|
+
prev.cacheWrite += inc.cacheWrite;
|
|
474
|
+
prev.total += inc.total;
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
modelTokenDistribution[model] = inc;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return {
|
|
481
|
+
...fresh.header,
|
|
482
|
+
agent: fresh.header.agent || existing.header.agent,
|
|
483
|
+
model: fresh.header.model || existing.header.model,
|
|
484
|
+
workingDirectory: fresh.header.workingDirectory || existing.header.workingDirectory,
|
|
485
|
+
// ISO 字符串可字典序比较:取更早者保留会话真实开始时间(进程重启不重置会话起点)
|
|
486
|
+
startTime: fresh.header.startTime < existing.header.startTime
|
|
487
|
+
? fresh.header.startTime
|
|
488
|
+
: existing.header.startTime,
|
|
489
|
+
title: fresh.header.title ?? existing.header.title,
|
|
490
|
+
agentSwitches: existing.header.agentSwitches + Math.max(0, fresh.header.agentSwitches - (baseline?.agentSwitches ?? 0)),
|
|
491
|
+
modelSwitches: existing.header.modelSwitches + Math.max(0, fresh.header.modelSwitches - (baseline?.modelSwitches ?? 0)),
|
|
492
|
+
agentUsage,
|
|
493
|
+
modelTokenDistribution,
|
|
494
|
+
};
|
|
495
|
+
})();
|
|
496
|
+
// codeStats 顶层分两类处理:累计型(fixCompileCount/totalCompileErrors)按差量并入;
|
|
497
|
+
// 快照型(etsLines/buildSuccess/firstBuildPassRate/firstBuildPerRound)在 fresh 无构建迹象
|
|
498
|
+
// (全零/空)时保留 existing——重启进程没跑过 hvigorw,fresh 全零是"不知道"而非"清零"。
|
|
499
|
+
const fixCompileCount = existing.codeStats.fixCompileCount
|
|
500
|
+
+ Math.max(0, fresh.codeStats.fixCompileCount - (baseline?.fixCompileCount ?? 0));
|
|
501
|
+
const totalCompileErrors = existing.codeStats.totalCompileErrors
|
|
502
|
+
+ Math.max(0, fresh.codeStats.totalCompileErrors - (baseline?.totalCompileErrors ?? 0));
|
|
503
|
+
const freshHasBuild = fresh.codeStats.etsLines > 0
|
|
504
|
+
|| fresh.codeStats.fixCompileCount > (baseline?.fixCompileCount ?? 0)
|
|
505
|
+
|| totalCompileErrors > existing.codeStats.totalCompileErrors
|
|
506
|
+
|| fresh.codeStats.firstBuildPerRound.length > 0;
|
|
507
|
+
const codeStats = {
|
|
508
|
+
etsLines: fresh.codeStats.etsLines > 0 ? fresh.codeStats.etsLines : existing.codeStats.etsLines,
|
|
509
|
+
buildSuccess: freshHasBuild ? fresh.codeStats.buildSuccess : existing.codeStats.buildSuccess,
|
|
510
|
+
fixCompileCount,
|
|
511
|
+
totalCompileErrors,
|
|
512
|
+
firstBuildPerRound: fresh.codeStats.firstBuildPerRound.length > 0
|
|
513
|
+
? fresh.codeStats.firstBuildPerRound
|
|
514
|
+
: existing.codeStats.firstBuildPerRound,
|
|
515
|
+
firstBuildPassRate: fresh.codeStats.firstBuildPerRound.length > 0
|
|
516
|
+
? fresh.codeStats.firstBuildPassRate
|
|
517
|
+
: existing.codeStats.firstBuildPassRate,
|
|
518
|
+
errorCodes,
|
|
519
|
+
warnings,
|
|
520
|
+
fixCycles,
|
|
521
|
+
moduleTimings,
|
|
522
|
+
};
|
|
282
523
|
return {
|
|
283
524
|
sessionId: fresh.sessionId,
|
|
525
|
+
procStartTime: fresh.procStartTime,
|
|
284
526
|
startTime,
|
|
285
527
|
endTime: fresh.endTime,
|
|
286
528
|
duration,
|
|
287
529
|
systemPrompts,
|
|
288
530
|
rounds,
|
|
289
|
-
tokens
|
|
290
|
-
tools
|
|
291
|
-
compactions
|
|
292
|
-
anomaly
|
|
293
|
-
stages
|
|
294
|
-
header
|
|
295
|
-
codeStats
|
|
296
|
-
|
|
297
|
-
buildSuccess: fresh.codeStats.buildSuccess,
|
|
298
|
-
fixCompileCount: fresh.codeStats.fixCompileCount,
|
|
299
|
-
totalCompileErrors: fresh.codeStats.totalCompileErrors,
|
|
300
|
-
firstBuildPerRound,
|
|
301
|
-
firstBuildPassRate: fresh.codeStats.firstBuildPassRate,
|
|
302
|
-
errorCodes,
|
|
303
|
-
warnings,
|
|
304
|
-
fixCycles,
|
|
305
|
-
moduleTimings,
|
|
306
|
-
},
|
|
307
|
-
responseLength: fresh.responseLength,
|
|
531
|
+
tokens,
|
|
532
|
+
tools,
|
|
533
|
+
compactions,
|
|
534
|
+
anomaly,
|
|
535
|
+
stages,
|
|
536
|
+
header,
|
|
537
|
+
codeStats,
|
|
538
|
+
responseLength,
|
|
308
539
|
skills,
|
|
309
540
|
skillSearches,
|
|
310
541
|
steps,
|
package/dist/metrics/types.d.ts
CHANGED
|
@@ -159,6 +159,8 @@ export interface StepData {
|
|
|
159
159
|
reasoning: string;
|
|
160
160
|
}
|
|
161
161
|
export interface RoundSnapshot {
|
|
162
|
+
/** 轮次开始时间戳(0.2.2 起;旧快照缺省,合并时回退按 index 对齐) */
|
|
163
|
+
startTime?: number;
|
|
162
164
|
roundIndex: number;
|
|
163
165
|
duration: number;
|
|
164
166
|
firstTokenLatency: number;
|
|
@@ -194,6 +196,8 @@ export interface MetricsOutput {
|
|
|
194
196
|
sessionId: string;
|
|
195
197
|
/** 快照来源标记(0.2.0 起仅 live) */
|
|
196
198
|
source?: "live";
|
|
199
|
+
/** 产生本快照的进程标记(state 创建时刻,0.3.5 起):同值=同进程;缺省=旧版快照 */
|
|
200
|
+
procStartTime?: number;
|
|
197
201
|
startTime: number;
|
|
198
202
|
endTime: number;
|
|
199
203
|
duration: number;
|
|
@@ -46,6 +46,12 @@ export interface AgcUploader {
|
|
|
46
46
|
* @param uploadPackage 归档产物(含双文件与路径元数据)
|
|
47
47
|
*/
|
|
48
48
|
uploadPackage(uploadPackage: UploadPackage): Promise<UploadOutcome>;
|
|
49
|
+
/**
|
|
50
|
+
* 预热 token:提前走一遍获取/读缓存流程并落盘缓存(结果忽略)。
|
|
51
|
+
* 进程启动时后台调用,dispose 触发的上传即可直接读缓存,
|
|
52
|
+
* 不必在退出的 10-60s 窗口内等首次 token 网络往返(最长 30s)。
|
|
53
|
+
*/
|
|
54
|
+
warmup(): Promise<void>;
|
|
49
55
|
}
|
|
50
56
|
/**
|
|
51
57
|
* 创建 AGC 上传器(token 自动获取与缓存 + 流式 PUT)。
|
|
@@ -4,6 +4,7 @@ import * as https from "node:https";
|
|
|
4
4
|
import * as os from "node:os";
|
|
5
5
|
import * as path from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { log } from "../../shared/log.js";
|
|
7
8
|
import { sanitizeNameSegment } from "./types.js";
|
|
8
9
|
/** region → 默认域名映射(未显式配置 api_domain/storage_domain 时使用) */
|
|
9
10
|
const REGION_DOMAINS = {
|
|
@@ -82,6 +83,15 @@ export function createAgcUploader(credentials) {
|
|
|
82
83
|
return archiveOutcome;
|
|
83
84
|
return uploadFile(credentials, tokenOutcome.token, uploadPackage.snapshot.filePath, `${remoteBase}.json`);
|
|
84
85
|
},
|
|
86
|
+
async warmup() {
|
|
87
|
+
const outcome = await getAccessToken(credentials);
|
|
88
|
+
if (outcome.ok) {
|
|
89
|
+
log.info("[Metrics] AGC token 预热完成");
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
log.warn("[Metrics] AGC token 预热失败(不影响运行,dispose 上传时仍会重试)", { reason: outcome.reason });
|
|
93
|
+
}
|
|
94
|
+
},
|
|
85
95
|
};
|
|
86
96
|
}
|
|
87
97
|
/** 获取 access token:优先读缓存(留 60s 余量),否则 client_credentials 换取并写缓存 */
|
|
@@ -57,6 +57,11 @@ export interface OrphanRescueConfig {
|
|
|
57
57
|
/** 快照最大补传年龄(天,缺省 7,超过视为陈旧放弃) */
|
|
58
58
|
maxAgeDays?: number;
|
|
59
59
|
}
|
|
60
|
+
/** 退出排空配置 */
|
|
61
|
+
export interface DisposeConfig {
|
|
62
|
+
/** dispose 排空上传队列的超时(毫秒,缺省 60000);超时未完成任务移入 failed/ 下次补偿 */
|
|
63
|
+
drainMs?: number;
|
|
64
|
+
}
|
|
60
65
|
/** opencode.json 的 upload 配置整块(§13.8) */
|
|
61
66
|
export interface UploadConfig {
|
|
62
67
|
/** 上传总开关,缺省 true(装上即上传);false 显式关闭 */
|
|
@@ -73,6 +78,7 @@ export interface UploadConfig {
|
|
|
73
78
|
retry?: UploadRetryConfig;
|
|
74
79
|
retryRescue?: RetryRescueConfig;
|
|
75
80
|
orphanRescue?: OrphanRescueConfig;
|
|
81
|
+
dispose?: DisposeConfig;
|
|
76
82
|
}
|
|
77
83
|
/** 暂存目录内的单个上传文件(归档包或快照) */
|
|
78
84
|
export interface UploadPackageFile {
|
|
@@ -25,6 +25,9 @@ export function createUploadManager(options) {
|
|
|
25
25
|
const uploader = createAgcUploader(credentialsResult.credentials);
|
|
26
26
|
const scenarios = resolveScenarios(config.scenarios);
|
|
27
27
|
const directories = options.dirs;
|
|
28
|
+
// token 预热:启动即后台获取并落缓存,dispose 触发的上传直接读缓存,
|
|
29
|
+
// 避免在退出窗口(drain 超时)内等首次 token 网络往返(最长 30s)
|
|
30
|
+
void uploader.warmup();
|
|
28
31
|
// 终态场景标签集合:这些标签的包上传成功即写已传标记(孤儿扫描防重依据)
|
|
29
32
|
const terminalLabels = new Set(scenarios
|
|
30
33
|
.filter((s) => s.enabled && (s.events.includes("session.deleted") || s.toolNames.length > 0))
|
|
@@ -251,7 +254,23 @@ export function createUploadManager(options) {
|
|
|
251
254
|
await triggerUpload(sessionId, scenario.label);
|
|
252
255
|
}
|
|
253
256
|
}
|
|
254
|
-
|
|
257
|
+
// 排空超时可配(默认 60s):归档同步完成,token 已预热读缓存,
|
|
258
|
+
// 剩余耗时只有上传本身;超时未完成的任务移入 failed/ 由下次启动补偿
|
|
259
|
+
const drainMs = config.dispose?.drainMs ?? 60_000;
|
|
260
|
+
const drained = await queue.drain(drainMs);
|
|
261
|
+
if (drained) {
|
|
262
|
+
log.info("[Metrics] dispose 排空完成", { timeoutMs: drainMs });
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const leftover = queue.snapshotPending();
|
|
266
|
+
for (const pkg of leftover) {
|
|
267
|
+
const metaPath = pkg.archive.filePath.replace(/\.tar\.gz$/, ".meta.json");
|
|
268
|
+
movePackageIntoFailed(directories.uploadStagingDir, pkg, metaPath);
|
|
269
|
+
}
|
|
270
|
+
log.warn("[Metrics] dispose 排空超时,剩余任务已移入 failed 待下次补偿", {
|
|
271
|
+
timeoutMs: drainMs,
|
|
272
|
+
remaining: leftover.length + (queue.pendingCount() - leftover.length),
|
|
273
|
+
});
|
|
255
274
|
}
|
|
256
275
|
/** 终态场景标签:事件含 session.deleted 的启用场景,缺省回退 session-end */
|
|
257
276
|
function terminalScenarioLabel() {
|
|
@@ -345,6 +364,7 @@ function sweepStagingDirectory(stagingDir, rescueEnabled) {
|
|
|
345
364
|
const names = new Set(entries);
|
|
346
365
|
const handled = new Set(["failed"]);
|
|
347
366
|
let removed = 0;
|
|
367
|
+
const removedNames = [];
|
|
348
368
|
for (const entry of entries) {
|
|
349
369
|
if (handled.has(entry))
|
|
350
370
|
continue;
|
|
@@ -358,6 +378,7 @@ function sweepStagingDirectory(stagingDir, rescueEnabled) {
|
|
|
358
378
|
else {
|
|
359
379
|
fs.rmSync(path.join(stagingDir, entry), { force: true });
|
|
360
380
|
removed++;
|
|
381
|
+
removedNames.push(entry);
|
|
361
382
|
}
|
|
362
383
|
handled.add(entry);
|
|
363
384
|
handled.add(`${base}.json`);
|
|
@@ -367,10 +388,11 @@ function sweepStagingDirectory(stagingDir, rescueEnabled) {
|
|
|
367
388
|
if (entry.endsWith(".json") || entry.startsWith(".assembly-")) {
|
|
368
389
|
fs.rmSync(path.join(stagingDir, entry), { recursive: true, force: true });
|
|
369
390
|
removed++;
|
|
391
|
+
removedNames.push(entry);
|
|
370
392
|
}
|
|
371
393
|
}
|
|
372
394
|
if (removed > 0)
|
|
373
|
-
log.info("[Metrics] 启动清扫暂存目录", { stagingDir, removed });
|
|
395
|
+
log.info("[Metrics] 启动清扫暂存目录", { stagingDir, removed, removedNames });
|
|
374
396
|
}
|
|
375
397
|
catch {
|
|
376
398
|
// 目录不存在:无需处理
|
package/dist/plugin.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createMetricsRuntime } from "./metrics/runtime.js";
|
|
2
2
|
import { resolvePluginConfig } from "./metrics/configLoader.js";
|
|
3
|
-
import { log } from "./shared/log.js";
|
|
3
|
+
import { log, flushLogs } from "./shared/log.js";
|
|
4
4
|
/**
|
|
5
5
|
* opencode 插件入口。
|
|
6
6
|
*
|
|
@@ -47,6 +47,8 @@ export const MetricsPlugin = async (input, options) => {
|
|
|
47
47
|
runtime.engine.dispose();
|
|
48
48
|
await runtime.uploadManager?.dispose();
|
|
49
49
|
log.info("[MetricsPlugin] disposed");
|
|
50
|
+
// 冲刷日志缓冲(500ms 定时落盘):退出窗口内的"上传完成/排空"日志不再丢失
|
|
51
|
+
flushLogs();
|
|
50
52
|
},
|
|
51
53
|
};
|
|
52
54
|
return hooks;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-metrics-plugin",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "opencode plugin: auto-records every session's metrics (tokens/rounds/steps/sub-agents/hvigor builds) to snapshot JSON. Zero DB, zero config. Pair with session-viewer for visualization.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|