opencode-metrics-plugin 0.3.2 → 0.3.3

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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.3(2026-09-12)
4
+
5
+ - session-end 三层保障,修复宿主进程突然死亡导致会话终态包永不产出的问题:
6
+ - **task 工具终态触发**:根会话的 task 工具 `completed` / `error` 即刻打包上传 session-end(子代理嵌套调用不触发,callID 去重);
7
+ - **打包前快照终稿化**:打包前先冲刷事件日志与引擎快照,包内快照必含触发事件本身(原 2s 冷却可能落后);
8
+ - **孤儿快照补传**:启动时扫描静默超 24h 且未上云终态的快照(`metrics/.sent/` 已传标记防重),按快照证据补打 session-end 包,超 7 天放弃(`upload.orphanRescue` 可调)。
9
+ - 场景系统支持 `tools` 工具名匹配器(内置 session-end 即 `tools: ["task"]`);`session.created` 即种子工作区证据,纯 task 会话也能完成根推断与包名推断。
10
+
3
11
  ## 0.3.2(2026-09-12)
4
12
 
5
13
  - 产物根推断增强:迁移工作区标记修正为 `.migration`;安卓工程根容器化降级(向内提升鸿蒙子工程与 `.migration`/`.harmonyos` 工作区);会话时间窗内新建的相邻鸿蒙工程纳入,邻居老工程不混入;纯安卓工程不打包。
package/README.md CHANGED
@@ -57,12 +57,13 @@
57
57
 
58
58
  默认开启(`upload.enabled: false` 可关):
59
59
 
60
- - **触发场景**:bash 执行 `git commit`(label=`git`)、会话结束/插件退出(label=`session-end`);可自定义场景(命令正则或事件名)
60
+ - **触发场景**:bash 执行 `git commit`(label=`git`);会话结束/插件退出(label=`session-end`);**根会话 task 工具完成或失败即刻触发 session-end**(子代理嵌套调用不触发)。可自定义场景(命令正则、事件名或工具名)
61
61
  - **打包内容**:时点快照 JSON + 事件日志(恒含)+ 会话动过的工作区产物根(鸿蒙工程标记探测,≤3 个)+ build 目录内的 `.hap` 产物包(借道组装目录,不受 build 黑名单影响)
62
62
  - **根推断增强**:安卓工程根(如 gradle 外壳)不整根保留也不丢弃——向内扫描提升鸿蒙子工程(`ohos/` 等)与迁移工作区(`.migration` / `.harmonyos`);会话期间新建的相邻鸿蒙工程(目录 mtime 在会话开始之后)也会纳入,邻居老工程不混入;纯安卓工程(无任何鸿蒙子根)不打包
63
63
  - **自动剔除**:黑名单目录(`oh_modules` / `build` / `node_modules` 等 8 项);安卓工程(根级 + 子树级,gradle 信号 ≥2 判定,可用 `excludeAndroidProjects: false` 关闭)
64
64
  - **云端路径**:`YYYYMMDD/<安卓包名或项目名>-<sessionId>/<agent缩写>-<触发>-v<n>/<agent缩写>-<触发>-v<n>.tar.gz` 与同名 `.json`(一个版本一个目录,双文件归拢;日期取包创建日)
65
65
  - **可靠性**:串行队列 + 指数退避重试(默认 5 次,4xx 不重试);失败包移入暂存目录 `failed/` 下**与云端同构的目录**(含 meta 元数据,可整目录手动上传),下次启动自动补偿重传,重传成功即清理;保留超期(默认 7 天)或超量(默认 1GB,从最旧删)自动清理
66
+ - **进程死亡兜底**:宿主突然退出(session-end 从未触发)时,下次启动对静默超 24h 且未传终态的快照自动补打 session-end 包(`metrics/.sent/` 已传标记防重传,超 7 天放弃)
66
67
 
67
68
  ## 输出目录
68
69
 
@@ -93,13 +94,14 @@
93
94
  |---|---|---|---|
94
95
  | `upload.enabled` | boolean | `true` | 上传总开关;`false` 显式关闭 |
95
96
  | `upload.uploader.agcConfigPath` | string | 包内凭证 | `agc-apiclient.json` 绝对路径(client_id/secret/project_id/region/bucket) |
96
- | `upload.scenarios` | object | 内置两场景 | 按 key 合并:`enabled:false` 禁用内置;自定义场景提供 `commands`(正则)或 `events`(事件名)与 `label`(进文件名) |
97
+ | `upload.scenarios` | object | 内置两场景 | 按 key 合并:`enabled:false` 禁用内置;自定义场景提供 `commands`(正则)/ `events`(事件名)/ `tools`(工具名,终态触发)与 `label`(进文件名) |
97
98
  | `upload.appendArtifacts` | array | — | 追加产物规则:`path`(绝对或相对会话工作目录)+ `matchBy`(`session-activity` 会话动过才纳入 / `existence` 存在即纳入)+ `dirMarkers`/`fileMarkers` |
98
99
  | `upload.blacklist` | string[] | 默认 8 项 | 打包排除的目录/文件名(追加,不替换) |
99
100
  | `upload.excludeAndroidProjects` | boolean | `true` | 剔除安卓工程(根级 + 根内子树) |
100
101
  | `upload.maxArtifactBytes` | number | `524288000` | 单包体积上限(默认 500MB),超限该次标失败 |
101
102
  | `upload.retry` | object | `{5, 1000}` | `maxAttempts` 最大尝试次数;`backoffMs` 退避基数(1s/4s/16s/64s/256s) |
102
103
  | `upload.retryRescue` | object | `{true, 7, 1GB}` | 失败保留与补偿重传:`enabled` 开关;`retentionDays` 保留天数;`maxFailedBytes` failed/ 总量上限(超限从最旧删) |
104
+ | `upload.orphanRescue` | object | `{true, 24, 7}` | 孤儿快照补传:`enabled` 开关;`idleHours` 静默下限(小时);`maxAgeDays` 最大快照年龄(天),超龄放弃 |
103
105
 
104
106
  ## 可编程 API
105
107
 
@@ -125,7 +127,7 @@ rt.engine.dispose()
125
127
 
126
128
  ```bash
127
129
  npm run typecheck # tsc --noEmit
128
- npm test # 冒烟测试(example/smoke.mts,71 断言)
130
+ npm test # 冒烟测试(example/smoke.mts,90 断言)
129
131
  npm run build # 清空 dist 后 tsc
130
132
  ```
131
133
 
@@ -21,7 +21,11 @@ export function createMetricsRuntime(opts = {}) {
21
21
  const uploadManager = createUploadManager({
22
22
  config: opts.upload,
23
23
  dirs,
24
- onBeforeArchive: () => eventLogger?.flush(),
24
+ onBeforeArchive: (sessionId) => {
25
+ eventLogger?.flush();
26
+ // 引擎快照即时落盘:包内快照为触发时刻终稿(而非落后 checkpoint 间隔的旧稿)
27
+ engine.flush(sessionId);
28
+ },
25
29
  });
26
30
  return {
27
31
  dirs,
@@ -1,5 +1,5 @@
1
1
  import type { ScenarioConfig } from "./types.js";
2
- /** 解析后的触发场景:label 用于文件命名,commands/events 为两类匹配源 */
2
+ /** 解析后的触发场景:label 用于文件命名,commands/events/tools 为三类匹配源 */
3
3
  export interface ResolvedScenario {
4
4
  key: string;
5
5
  /** 命名标签(文件名 <sessionId>-<agentName>-<label>-v<n> 的一段) */
@@ -9,12 +9,25 @@ export interface ResolvedScenario {
9
9
  commandRegexes: RegExp[];
10
10
  /** 匹配的事件名(含特殊值 "dispose",由管理器结束时触发) */
11
11
  events: string[];
12
+ /** 匹配的工具名(根会话直接调用的工具 completed/error 时触发) */
13
+ toolNames: string[];
12
14
  }
13
15
  /**
14
- * 合并内置场景与 opencode.json 配置:内置 key 深合并(label/commands/events 可覆盖、
15
- * enabled:false 可禁用),自定义 key 直接新增;非法正则跳过并记日志。
16
+ * 合并内置场景与 opencode.json 配置:内置 key 深合并(label/commands/events/tools
17
+ * 可覆盖、enabled:false 可禁用),自定义 key 直接新增;非法正则跳过并记日志。
16
18
  *
17
19
  * @param configs opencode.json upload.scenarios
18
20
  * @returns 全部场景(含禁用的,调用方按 enabled 过滤)
19
21
  */
20
22
  export declare function resolveScenarios(configs?: Record<string, ScenarioConfig>): ResolvedScenario[];
23
+ /**
24
+ * 判定工具终态事件命中的场景:根会话直接调用的工具进入终态
25
+ * (completed/error)时命中其 toolNames;子代理嵌套调用不命中。
26
+ *
27
+ * @param scenarios 已解析场景(含禁用项,函数内过滤 enabled)
28
+ * @param tool 工具名
29
+ * @param status 工具状态(仅 completed/error 视为终态)
30
+ * @param isRootSession 事件原始会话是否根会话(未被子代理映射)
31
+ * @returns 命中的启用场景列表
32
+ */
33
+ export declare function matchToolScenarios(scenarios: ResolvedScenario[], tool: string, status: string, isRootSession: boolean): ResolvedScenario[];
@@ -1,12 +1,16 @@
1
1
  import { log } from "../../shared/log.js";
2
2
  /** 内置默认场景(opencode.json scenarios 按 key 合并覆盖) */
3
3
  const BUILTIN_SCENARIOS = {
4
- "session-end": { label: "session-end", events: ["dispose", "session.deleted"] },
4
+ "session-end": {
5
+ label: "session-end",
6
+ events: ["dispose", "session.deleted"],
7
+ tools: ["task"],
8
+ },
5
9
  "git-commit": { label: "git", commands: ["\\bgit\\s+commit\\b"] },
6
10
  };
7
11
  /**
8
- * 合并内置场景与 opencode.json 配置:内置 key 深合并(label/commands/events 可覆盖、
9
- * enabled:false 可禁用),自定义 key 直接新增;非法正则跳过并记日志。
12
+ * 合并内置场景与 opencode.json 配置:内置 key 深合并(label/commands/events/tools
13
+ * 可覆盖、enabled:false 可禁用),自定义 key 直接新增;非法正则跳过并记日志。
10
14
  *
11
15
  * @param configs opencode.json upload.scenarios
12
16
  * @returns 全部场景(含禁用的,调用方按 enabled 过滤)
@@ -37,7 +41,25 @@ export function resolveScenarios(configs) {
37
41
  enabled: config.enabled !== false,
38
42
  commandRegexes,
39
43
  events: config.events ?? [],
44
+ toolNames: config.tools ?? [],
40
45
  });
41
46
  }
42
47
  return result;
43
48
  }
49
+ /**
50
+ * 判定工具终态事件命中的场景:根会话直接调用的工具进入终态
51
+ * (completed/error)时命中其 toolNames;子代理嵌套调用不命中。
52
+ *
53
+ * @param scenarios 已解析场景(含禁用项,函数内过滤 enabled)
54
+ * @param tool 工具名
55
+ * @param status 工具状态(仅 completed/error 视为终态)
56
+ * @param isRootSession 事件原始会话是否根会话(未被子代理映射)
57
+ * @returns 命中的启用场景列表
58
+ */
59
+ export function matchToolScenarios(scenarios, tool, status, isRootSession) {
60
+ if (!isRootSession || !tool)
61
+ return [];
62
+ if (status !== "completed" && status !== "error")
63
+ return [];
64
+ return scenarios.filter((scenario) => scenario.enabled && scenario.toolNames.includes(tool));
65
+ }
@@ -25,6 +25,8 @@ export interface ScenarioConfig {
25
25
  commands?: string[];
26
26
  /** TRACKED_EVENT_TYPES 内事件名 */
27
27
  events?: string[];
28
+ /** 工具名匹配器:根会话直接调用的工具进入终态(completed/error)时触发 */
29
+ tools?: string[];
28
30
  }
29
31
  /** 上传通道配置(当前仅 agc) */
30
32
  export interface UploaderConfig {
@@ -46,6 +48,15 @@ export interface RetryRescueConfig {
46
48
  /** failed/ 总量字节上限,超限从最旧删除(缺省 1GB) */
47
49
  maxFailedBytes?: number;
48
50
  }
51
+ /** 孤儿快照补传:宿主死亡导致终态触发从未发生时,启动后按快照年龄补打 session-end 包 */
52
+ export interface OrphanRescueConfig {
53
+ /** 缺省 true:启用孤儿快照补传 */
54
+ enabled?: boolean;
55
+ /** 快照静默多久视为孤儿(小时,缺省 24;活跃会话快照始终排除) */
56
+ idleHours?: number;
57
+ /** 快照最大补传年龄(天,缺省 7,超过视为陈旧放弃) */
58
+ maxAgeDays?: number;
59
+ }
49
60
  /** opencode.json 的 upload 配置整块(§13.8) */
50
61
  export interface UploadConfig {
51
62
  /** 上传总开关,缺省 true(装上即上传);false 显式关闭 */
@@ -61,6 +72,7 @@ export interface UploadConfig {
61
72
  maxArtifactBytes?: number;
62
73
  retry?: UploadRetryConfig;
63
74
  retryRescue?: RetryRescueConfig;
75
+ orphanRescue?: OrphanRescueConfig;
64
76
  }
65
77
  /** 暂存目录内的单个上传文件(归档包或快照) */
66
78
  export interface UploadPackageFile {
@@ -4,8 +4,8 @@ export interface UploadManagerOptions {
4
4
  config?: UploadConfig;
5
5
  /** 实例目录(metricsDir/eventsDir/uploadStagingDir) */
6
6
  dirs: MetricsDirs;
7
- /** 归档前回调(事件日志冲刷落盘,消除缓冲延迟导致的空 events 竞态) */
8
- onBeforeArchive?: () => void;
7
+ /** 归档前回调(事件日志冲刷 + 引擎快照落盘,消除缓冲延迟导致的旧快照/空 events 竞态) */
8
+ onBeforeArchive?: (sessionId: string) => void;
9
9
  }
10
10
  export interface UploadManager {
11
11
  /** 旁路观察事件:收集证据 + 场景命中判定(须在 engine.ingest 之后调用,保证快照先落盘) */
@@ -23,3 +23,30 @@ export interface UploadManager {
23
23
  * @param options 配置与目录
24
24
  */
25
25
  export declare function createUploadManager(options: UploadManagerOptions): UploadManager | null;
26
+ /**
27
+ * 记录会话终态包已成功上云。
28
+ * 快照原件(metrics/<sessionId>.json)会永久保留为本地记录,
29
+ * "已传标记"才是孤儿扫描防重复上传的依据。
30
+ */
31
+ export declare function markSessionUploaded(metricsDir: string, sessionId: string): void;
32
+ /** 会话终态包是否已成功上云 */
33
+ export declare function isSessionUploaded(metricsDir: string, sessionId: string): boolean;
34
+ /** 孤儿快照候选:从磁盘快照恢复的会话证据 */
35
+ export interface OrphanSnapshot {
36
+ sessionId: string;
37
+ filePath: string;
38
+ workingDirectory: string;
39
+ agentName: string;
40
+ startedAt: number;
41
+ }
42
+ /**
43
+ * 扫描目录选出孤儿快照候选:mtime 静默 [idleMs, maxAgeMs] 且不在活跃集的快照。
44
+ * 上传成功后快照即被删除,"文件仍在"天然等价于"终态未传出"。
45
+ *
46
+ * @param metricsDir 快照目录
47
+ * @param idleMs 静默下限(毫秒)
48
+ * @param maxAgeMs 年龄上限(毫秒)
49
+ * @param isActive 活跃会话判定(IO 前后各查一次,防竞态)
50
+ * @returns 候选列表(无法解析的快照跳过)
51
+ */
52
+ export declare function selectOrphanSnapshots(metricsDir: string, idleMs: number, maxAgeMs: number, isActive: (sessionId: string) => boolean): Promise<OrphanSnapshot[]>;
@@ -5,7 +5,7 @@ import { EvidenceCollector, isPathInside, resolveAndroidProjectName, resolveWork
5
5
  import { archiveSessionPackage } from "./packageArchiver.js";
6
6
  import { createAgcUploader, composeRemoteBase, defaultAgcConfigPath, loadAgcCredentials, remoteArchiveKey } from "./agcUploader.js";
7
7
  import { createUploadQueue } from "./uploadQueue.js";
8
- import { resolveScenarios } from "./scenarios.js";
8
+ import { resolveScenarios, matchToolScenarios } from "./scenarios.js";
9
9
  import { MAX_ROOTS } from "./types.js";
10
10
  /**
11
11
  * 创建上传管理器:串联证据 → 根推断 → 打包 → 队列 → AGC 上传,
@@ -25,6 +25,10 @@ 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
+ // 终态场景标签集合:这些标签的包上传成功即写已传标记(孤儿扫描防重依据)
29
+ const terminalLabels = new Set(scenarios
30
+ .filter((s) => s.enabled && (s.events.includes("session.deleted") || s.toolNames.length > 0))
31
+ .map((s) => s.label));
28
32
  // 失败保留与启动补偿配置(默认开启)
29
33
  const rescueEnabled = config.retryRescue?.enabled !== false;
30
34
  const retentionMs = (config.retryRescue?.retentionDays ?? 7) * 24 * 60 * 60 * 1000;
@@ -41,6 +45,10 @@ export function createUploadManager(options) {
41
45
  removeQuietly(uploadPackage.archive.filePath);
42
46
  removeQuietly(uploadPackage.snapshot.filePath);
43
47
  removeQuietly(metaPath);
48
+ // 终态包上云成功:写已传标记,孤儿扫描据此跳过该会话
49
+ if (terminalLabels.has(uploadPackage.trigger)) {
50
+ markSessionUploaded(directories.metricsDir, uploadPackage.sessionId);
51
+ }
44
52
  // 来自 failed/ 的补偿重传:清掉可能残留的空目录
45
53
  removeEmptyDirsUp(path.dirname(uploadPackage.archive.filePath), path.join(directories.uploadStagingDir, "failed"));
46
54
  log.info("[Metrics] 上传完成", {
@@ -81,9 +89,16 @@ export function createUploadManager(options) {
81
89
  for (const pkg of rescued)
82
90
  queue.enqueue(pkg);
83
91
  }
92
+ // 孤儿快照补传:宿主死亡导致终态触发从未发生时,按快照年龄补打 session-end 包
93
+ if (config.orphanRescue?.enabled !== false) {
94
+ const idleMs = (config.orphanRescue?.idleHours ?? 24) * 3_600_000;
95
+ const maxAgeMs = (config.orphanRescue?.maxAgeDays ?? 7) * 86_400_000;
96
+ void rescueOrphanSnapshots(idleMs, maxAgeMs);
97
+ }
84
98
  const sessions = new Map();
85
99
  const childToParent = new Map();
86
100
  const completedBashCalls = new Set();
101
+ const toolTriggeredCalls = new Set();
87
102
  function resolveSessionId(rawId) {
88
103
  return childToParent.get(rawId) ?? rawId;
89
104
  }
@@ -98,11 +113,13 @@ export function createUploadManager(options) {
98
113
  /**
99
114
  * 触发一次上传:根推断 + 包名推断 + 打包入队。
100
115
  *
101
- * @param sessionId 会话 id
102
- * @param triggerLabel 场景命名标签
116
+ * @param sessionId 会话 id
117
+ * @param triggerLabel 场景命名标签
118
+ * @param evidenceOverride 证据覆盖(孤儿补传时由快照构造,代替内存证据)
103
119
  */
104
- async function triggerUpload(sessionId, triggerLabel) {
105
- const evidence = sessions.get(sessionId) ??
120
+ async function triggerUpload(sessionId, triggerLabel, evidenceOverride) {
121
+ const evidence = evidenceOverride ??
122
+ sessions.get(sessionId) ??
106
123
  { collector: new EvidenceCollector(), agentName: "", workingDirectory: "", startedAt: Date.now() };
107
124
  const seeds = evidence.collector.seeds();
108
125
  const { directRoots, extraMarkers } = collectArtifactRules(seeds, evidence.workingDirectory, config.appendArtifacts ?? []);
@@ -116,7 +133,7 @@ export function createUploadManager(options) {
116
133
  const projectName = resolveAndroidProjectName(seeds, {
117
134
  fallbackDirectory: evidence.workingDirectory || roots[0] || "",
118
135
  }) ?? "project";
119
- options.onBeforeArchive?.();
136
+ options.onBeforeArchive?.(sessionId);
120
137
  const archiveResult = await archiveSessionPackage({
121
138
  sessionId,
122
139
  trigger: triggerLabel,
@@ -151,8 +168,12 @@ export function createUploadManager(options) {
151
168
  const evidence = getOrCreateEvidence(resolveSessionId(id));
152
169
  if (event.type === "session.created") {
153
170
  const directory = info.directory;
154
- if (directory)
171
+ if (directory) {
155
172
  evidence.workingDirectory = directory;
173
+ // 工作目录作为证据种子:即使全程无 write/bash 证据(如纯 task 转换会话),
174
+ // 触发上传时也能扫描工作区完成根推断与包名推断
175
+ evidence.collector.recordBash(directory, "");
176
+ }
156
177
  }
157
178
  else {
158
179
  const agent = info.agent;
@@ -171,18 +192,19 @@ export function createUploadManager(options) {
171
192
  if (!part || part.type !== "tool")
172
193
  return;
173
194
  const toolState = part.state;
174
- if (toolState?.status !== "completed")
195
+ const status = toolState?.status;
196
+ if (status !== "completed" && status !== "error")
175
197
  return;
176
198
  const tool = part.tool;
177
- const input = toolState.input;
199
+ const input = toolState?.input;
178
200
  const callID = part.callID;
179
- if (tool === "write" || tool === "edit") {
201
+ if (status === "completed" && (tool === "write" || tool === "edit")) {
180
202
  const filePath = input?.filePath || input?.path;
181
203
  if (filePath)
182
204
  evidence.collector.recordFileEdit(filePath);
183
205
  return;
184
206
  }
185
- if (tool === "bash") {
207
+ if (status === "completed" && tool === "bash") {
186
208
  const command = input?.command || "";
187
209
  const workdir = input?.workdir;
188
210
  evidence.collector.recordBash(workdir, command);
@@ -198,6 +220,18 @@ export function createUploadManager(options) {
198
220
  void triggerUpload(sessionId, scenario.label);
199
221
  }
200
222
  }
223
+ return;
224
+ }
225
+ // 工具名场景命中:根会话直接调用的工具进入终态(completed/error)即触发
226
+ // (task 委派返回即业务终态,失败也存终态;子代理内部的嵌套调用不触发)
227
+ const matched = matchToolScenarios(scenarios, tool ?? "", status ?? "", !childToParent.has(rawSessionId));
228
+ if (matched.length > 0 && (!callID || !toolTriggeredCalls.has(callID))) {
229
+ if (callID)
230
+ toolTriggeredCalls.add(callID);
231
+ for (const scenario of matched) {
232
+ log.info("[Metrics] 工具终态触发", { sessionId, tool, status, scenario: scenario.label });
233
+ void triggerUpload(sessionId, scenario.label);
234
+ }
201
235
  }
202
236
  return;
203
237
  }
@@ -219,6 +253,40 @@ export function createUploadManager(options) {
219
253
  }
220
254
  await queue.drain(10_000);
221
255
  }
256
+ /** 终态场景标签:事件含 session.deleted 的启用场景,缺省回退 session-end */
257
+ function terminalScenarioLabel() {
258
+ return scenarios.find((s) => s.enabled && s.events.includes("session.deleted"))?.label ?? "session-end";
259
+ }
260
+ /**
261
+ * 扫描 metrics 目录孤儿快照并补打 session-end 包:mtime 静默 [idleMs, maxAgeMs]
262
+ * 且不属于本进程活跃会话的快照视为孤儿。上传成功后快照即被删除,
263
+ * "文件仍在"天然等价于"终态未传出",无需额外标记。
264
+ *
265
+ * @param idleMs 静默下限(毫秒)
266
+ * @param maxAgeMs 年龄上限(毫秒)
267
+ */
268
+ async function rescueOrphanSnapshots(idleMs, maxAgeMs) {
269
+ const triggerLabel = terminalScenarioLabel();
270
+ const orphans = await selectOrphanSnapshots(directories.metricsDir, idleMs, maxAgeMs, (sessionId) => sessions.has(sessionId));
271
+ for (const orphan of orphans) {
272
+ try {
273
+ // 工作目录作为证据种子:推断产物根与项目名(内存证据已随进程丢失)
274
+ const collector = new EvidenceCollector();
275
+ if (orphan.workingDirectory)
276
+ collector.recordBash(orphan.workingDirectory, "");
277
+ await triggerUpload(orphan.sessionId, triggerLabel, {
278
+ collector,
279
+ agentName: orphan.agentName,
280
+ workingDirectory: orphan.workingDirectory,
281
+ startedAt: orphan.startedAt,
282
+ });
283
+ log.info("[Metrics] 孤儿快照补传", { sessionId: orphan.sessionId, trigger: triggerLabel });
284
+ }
285
+ catch (err) {
286
+ log.warn("[Metrics] 孤儿快照补传失败", { sessionId: orphan.sessionId, error: String(err) });
287
+ }
288
+ }
289
+ }
222
290
  return { observe, dispose };
223
291
  }
224
292
  /** 应用 appendArtifacts 规则:marker 进探测扩展,显式路径按 matchBy 命中后作为直接根 */
@@ -467,3 +535,78 @@ function removeQuietly(filePath) {
467
535
  // 删除失败只记日志层面忽略
468
536
  }
469
537
  }
538
+ // ─── 孤儿快照补传(§13 失败补偿链路) ─────────────────────────────────────────
539
+ /** 已传终态标记目录名(metrics/.sent/<sessionId>) */
540
+ const SENT_MARKER_DIR = ".sent";
541
+ /**
542
+ * 记录会话终态包已成功上云。
543
+ * 快照原件(metrics/<sessionId>.json)会永久保留为本地记录,
544
+ * "已传标记"才是孤儿扫描防重复上传的依据。
545
+ */
546
+ export function markSessionUploaded(metricsDir, sessionId) {
547
+ try {
548
+ fs.mkdirSync(path.join(metricsDir, SENT_MARKER_DIR), { recursive: true });
549
+ fs.writeFileSync(path.join(metricsDir, SENT_MARKER_DIR, sessionId), String(Date.now()));
550
+ }
551
+ catch {
552
+ // 标记失败仅导致下次启动多一次补传判断,不阻塞上传链路
553
+ }
554
+ }
555
+ /** 会话终态包是否已成功上云 */
556
+ export function isSessionUploaded(metricsDir, sessionId) {
557
+ return fs.existsSync(path.join(metricsDir, SENT_MARKER_DIR, sessionId));
558
+ }
559
+ /**
560
+ * 扫描目录选出孤儿快照候选:mtime 静默 [idleMs, maxAgeMs] 且不在活跃集的快照。
561
+ * 上传成功后快照即被删除,"文件仍在"天然等价于"终态未传出"。
562
+ *
563
+ * @param metricsDir 快照目录
564
+ * @param idleMs 静默下限(毫秒)
565
+ * @param maxAgeMs 年龄上限(毫秒)
566
+ * @param isActive 活跃会话判定(IO 前后各查一次,防竞态)
567
+ * @returns 候选列表(无法解析的快照跳过)
568
+ */
569
+ export async function selectOrphanSnapshots(metricsDir, idleMs, maxAgeMs, isActive) {
570
+ const result = [];
571
+ let entries;
572
+ try {
573
+ entries = await fs.promises.readdir(metricsDir);
574
+ }
575
+ catch {
576
+ return result;
577
+ }
578
+ const now = Date.now();
579
+ for (const entry of entries) {
580
+ if (!entry.endsWith(".json"))
581
+ continue;
582
+ const sessionId = entry.slice(0, -".json".length);
583
+ if (isSessionUploaded(metricsDir, sessionId))
584
+ continue;
585
+ const filePath = path.join(metricsDir, entry);
586
+ try {
587
+ const stats = await fs.promises.stat(filePath);
588
+ const age = now - stats.mtimeMs;
589
+ if (age < idleMs || age > maxAgeMs)
590
+ continue;
591
+ if (isActive(sessionId))
592
+ continue;
593
+ const snapshot = JSON.parse(await fs.promises.readFile(filePath, "utf-8"));
594
+ if (isActive(sessionId))
595
+ continue;
596
+ const header = (snapshot.header ?? {});
597
+ result.push({
598
+ sessionId,
599
+ filePath,
600
+ workingDirectory: header.workingDirectory || "",
601
+ agentName: header.agent || "",
602
+ startedAt: (typeof snapshot.startTime === "number" && snapshot.startTime > 0 && snapshot.startTime) ||
603
+ Date.parse(header.startTime || "") ||
604
+ stats.mtimeMs,
605
+ });
606
+ }
607
+ catch {
608
+ // 无法解析的快照跳过,不阻塞其余候选
609
+ }
610
+ }
611
+ return result;
612
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
- {
1
+ {
2
2
  "name": "opencode-metrics-plugin",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
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",