dsh-subagent-profile 0.3.3 → 0.4.0

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.
Files changed (42) hide show
  1. package/README.md +49 -20
  2. package/README.zh.md +102 -73
  3. package/docs/screenshots/dispatch-card.png +0 -0
  4. package/docs/screenshots/settings-page1.png +0 -0
  5. package/docs/screenshots/settings-page2.png +0 -0
  6. package/index.mjs +153 -75
  7. package/lib/client.js +3081 -444
  8. package/lib/core/adoption-reminder.mjs +48 -0
  9. package/lib/core/adoption-tracker.mjs +430 -0
  10. package/lib/core/background-ledger.mjs +71 -0
  11. package/lib/core/cost-evidence.mjs +145 -0
  12. package/lib/core/cost-guard.mjs +1 -1
  13. package/lib/core/decision-trace.mjs +11 -31
  14. package/lib/core/delegation.mjs +11 -7
  15. package/lib/core/dispatch-gates.mjs +12 -5
  16. package/lib/core/dispatch-guard.mjs +6 -0
  17. package/lib/core/dispatch-schema.mjs +10 -5
  18. package/lib/core/dispatch-tool.mjs +52 -45
  19. package/lib/core/draft-gates.mjs +45 -0
  20. package/lib/core/drafts-store.mjs +45 -0
  21. package/lib/core/evolution-advice.mjs +323 -0
  22. package/lib/core/evolution-assets.mjs +167 -0
  23. package/lib/core/evolution-draft.mjs +312 -0
  24. package/lib/core/evolution-engine.mjs +269 -0
  25. package/lib/core/evolution-generate.mjs +196 -0
  26. package/lib/core/evolution-ledger.mjs +149 -33
  27. package/lib/core/evolution-persistence.mjs +213 -0
  28. package/lib/core/evolution-renewal.mjs +64 -0
  29. package/lib/core/evolution-routes.mjs +109 -0
  30. package/lib/core/evolution-summary.mjs +46 -223
  31. package/lib/core/http-helpers.mjs +35 -0
  32. package/lib/core/http-routes.mjs +169 -115
  33. package/lib/core/presets-sync.mjs +254 -256
  34. package/lib/core/prices.mjs +46 -0
  35. package/lib/core/profile-directory.mjs +139 -0
  36. package/lib/core/profile-provider.mjs +5 -3
  37. package/lib/core/profiles-store.mjs +24 -6
  38. package/lib/core/reminder-store.mjs +172 -0
  39. package/lib/core/session-read.mjs +17 -0
  40. package/lib/core/shims.mjs +13 -2
  41. package/package.json +82 -82
  42. package/presets/orchestrator/agent.cordis.yml +243 -243
@@ -0,0 +1,196 @@
1
+ // lib/core/evolution-generate.mjs — 候选生成路径(从 evolution-engine 拆出守行门):
2
+ // 一条建议 → ≤3 条互斥候选,逐候选过三道闸(闸由装配层注入)后落资产域。更新策略
3
+ // 与有效期用户可配:自动换新在「候选到期且数据显著变化」时把旧候选转 expired(留
4
+ // 审计)再产新一代;手动维护不自动换代、不自动过期,只经「重新生成候选」刷新
5
+ // (留审计)。纯函数 + 注入 deps,零 IO、零 LLM。
6
+
7
+ import { sanitizeProfile } from './pure.mjs';
8
+ import { profileKeyOf } from './evolution-summary.mjs';
9
+ import { buildEvolutionCandidates, profileFingerprint, sourceConfigFromKey } from './evolution-draft.mjs';
10
+ import { candidateExpired, candidateExpiryOf, dataFingerprintOf, dataSignificantlyChanged, expireAuditEntry, refreshAuditEntry } from './evolution-renewal.mjs';
11
+
12
+ // 候选总量护栏:evolution 资产数达到 20 条即跳过生成并 warn(阈值待实测后校准)。
13
+ const ASSET_CAP = 20;
14
+
15
+ // 存量 profile 的 L1 身份键(与聚合层同口径:persona/toolFilter 只认存在性)。
16
+ function keyCfg(profile) {
17
+ const cfg = {};
18
+ if (typeof profile.preset === 'string' && profile.preset !== '' && profile.preset !== 'inherit') cfg.preset = profile.preset;
19
+ if (typeof profile.provider === 'string' && profile.provider !== '') cfg.provider = profile.provider;
20
+ if (typeof profile.model === 'string' && profile.model !== '') cfg.model = profile.model;
21
+ if (typeof profile.persona === 'string' && profile.persona.length > 0) cfg.persona_present = true;
22
+ if (profile.toolFilter !== undefined && profile.toolFilter !== null) cfg.toolFilter_present = true;
23
+ return cfg;
24
+ }
25
+
26
+ // 注册表中与该 L1 身份键匹配的方案(净化后快照)——指纹输入,编辑任一方案
27
+ // 都会使候选过期。
28
+ export function matchingRegistryProfiles(store, profileKey) {
29
+ const profiles = store.profiles instanceof Map ? [...store.profiles.values()] : [];
30
+ return profiles
31
+ .filter((p) => p !== null && typeof p === 'object' && profileKeyOf(keyCfg(p)) === profileKey)
32
+ .map((p) => sanitizeProfile(p, { strict: false }).clean);
33
+ }
34
+
35
+ // 单条候选 → 资产对象。baseline_snapshot 留档金丝雀基线(from + 生成时刻的加权分/
36
+ // 样本数);data_fingerprint 留档换代判定输入;expiry 按用户设置(手动维护或不自动
37
+ // 过期档为 null,否则 = 生成时刻 + 有效期小时数)。
38
+ export function buildAsset(deps, item, draft, now, summaries) {
39
+ const l1Entry = summaries !== null && typeof summaries === 'object' && summaries.l1 !== null && typeof summaries.l1 === 'object'
40
+ ? summaries.l1[item.profileKey]
41
+ : undefined;
42
+ const l2 = summaries !== null && typeof summaries === 'object' ? summaries.l2 : undefined;
43
+ if (draft === null || typeof draft !== 'object') return null;
44
+ const from = sourceConfigFromKey(item.profileKey, l2);
45
+ const registry = matchingRegistryProfiles(deps.store, item.profileKey);
46
+ const fingerprint = profileFingerprint({ profileKey: item.profileKey, from, registryEntries: registry });
47
+ const axis = item.suggestion === '降' ? 'capability' : 'budget';
48
+ const direction = item.suggestion === '降' ? 'down' : 'up';
49
+ return {
50
+ v: 1,
51
+ id: draft.config.id,
52
+ source: 'evolution',
53
+ provenance: { pluginVersion: deps.pluginVersion, generatedAt: now, basis: draft.basis },
54
+ state: 'draft',
55
+ version: 1,
56
+ expiry: candidateExpiryOf(deps.getCandidateMode(), deps.getCandidateTtlH(), now),
57
+ from,
58
+ to: draft.config,
59
+ axis,
60
+ direction,
61
+ baseline_snapshot: {
62
+ from,
63
+ score: l1Entry?.score ?? null,
64
+ n: l1Entry?.confidence?.n ?? 0,
65
+ confidence: item.confidence ?? '',
66
+ generatedAt: now,
67
+ },
68
+ data_fingerprint: dataFingerprintOf(item, summaries),
69
+ profile_key: item.profileKey,
70
+ profile_fingerprint: fingerprint,
71
+ config: draft.config,
72
+ name: draft.name,
73
+ description: draft.description,
74
+ createdAt: now,
75
+ updatedAt: now,
76
+ };
77
+ }
78
+
79
+ // 单条建议 → ≤3 条候选,逐候选过三道闸(复用装配层注入的闸评估)后落盘;单条
80
+ // 候选闸不过或落盘失败只跳过该候选(fail-soft),不影响其余候选与建议展示。
81
+ async function generateCandidatesFor(deps, { item, snapshot, taken, now, summaries }) {
82
+ const l1Entry = summaries !== null && typeof summaries === 'object' && summaries.l1 !== null && typeof summaries.l1 === 'object'
83
+ ? summaries.l1[item.profileKey]
84
+ : undefined;
85
+ const l2 = summaries !== null && typeof summaries === 'object' ? summaries.l2 : undefined;
86
+ const drafts = buildEvolutionCandidates({
87
+ advice: item,
88
+ l1Entry,
89
+ l2,
90
+ snapshot,
91
+ whitelist: deps.whitelist,
92
+ takenIds: taken,
93
+ now,
94
+ });
95
+ const generated = [];
96
+ const skipped = [];
97
+ for (const draft of drafts) {
98
+ const gates = await deps.assessGates(draft.config);
99
+ if (!gates.ok) {
100
+ skipped.push({ profileKey: item.profileKey, candidateId: draft.config.id, reason: gates.reason });
101
+ continue;
102
+ }
103
+ const asset = buildAsset(deps, item, draft, now, summaries);
104
+ if (asset === null) continue;
105
+ const persisted = deps.assets.add(asset);
106
+ if (persisted.persisted) {
107
+ taken.add(asset.id);
108
+ generated.push(asset.id);
109
+ } else {
110
+ skipped.push({ profileKey: item.profileKey, candidateId: draft.config.id, reason: 'persist-failed' });
111
+ }
112
+ }
113
+ return { generated, skipped };
114
+ }
115
+
116
+ // auto 模式的待处置门槛:null = 可生成;{skip} = 幂等跳过;{expire} = 需先转
117
+ // expired 再换代。观察期(proposed)恒阻断;手动维护不自动换代。
118
+ function pendingGate(deps, item, now, summaries, mode) {
119
+ const unresolved = deps.assets.findUnresolved(item.profileKey);
120
+ if (unresolved.length === 0) return null;
121
+ if (mode === 'manual' || unresolved.some((entry) => entry.state === 'proposed')) return { skip: 'pending' };
122
+ const expired = unresolved.filter((entry) => candidateExpired(entry, now));
123
+ if (expired.length < unresolved.length) return { skip: 'pending' };
124
+ const next = dataFingerprintOf(item, summaries);
125
+ const changed = expired.some((entry) => {
126
+ const asset = deps.assets.get(entry.id);
127
+ return dataSignificantlyChanged(asset !== undefined ? (asset.data_fingerprint ?? {}) : {}, next);
128
+ });
129
+ return changed ? { expire: expired } : { skip: 'no-significant-change' };
130
+ }
131
+
132
+ // 生成候选(建议 → 资产域):建议链开关门(evolutionAdvice)→ 总量护栏 → 逐条建议:
133
+ // auto:无未处置候选才生成;未到期幂等跳过;全部到期且数据显著变化 → 旧候选转
134
+ // expired(留审计)再产新一代;到期但数据没显著变化 → 保留旧候选跳过。
135
+ // manual:有任何未处置候选即幂等跳过(不自动换代、不自动过期)。
136
+ // forceProfileKey:只对该键绕过上述待处置门槛(「重新生成候选」内部复用)。
137
+ export async function generateCandidates(deps, { advice, summaries, forceProfileKey = null } = {}) {
138
+ if (!deps.getAdviceEnabled()) return { generated: [], skipped: [], reason: 'advice-disabled' };
139
+ const snapshot = await deps.catalog.getSnapshot();
140
+ const now = Date.now();
141
+ const mode = deps.getCandidateMode();
142
+ const evolutionCount = deps.assets.countBySource('evolution');
143
+ if (evolutionCount >= ASSET_CAP) {
144
+ deps.logger.warn('[dsh-subagent-profile] 候选总量护栏:evolution 资产已达 20 条上限,跳过生成(阈值待实测后校准)');
145
+ return { generated: [], skipped: [] };
146
+ }
147
+ const taken = new Set([...deps.store.profiles.keys(), ...deps.assets.ids()]);
148
+ const generated = [];
149
+ const skipped = [];
150
+ for (const item of Array.isArray(advice) ? advice : []) {
151
+ if (evolutionCount + generated.length >= ASSET_CAP) break;
152
+ const forced = forceProfileKey !== null && item.profileKey === forceProfileKey;
153
+ if (!forced) {
154
+ const gate = pendingGate(deps, item, now, summaries, mode);
155
+ if (gate === null) { /* 无未处置候选,正常生成 */ }
156
+ else if (gate.skip !== undefined) { skipped.push({ profileKey: item.profileKey, reason: gate.skip }); continue; }
157
+ else {
158
+ for (const entry of gate.expire) {
159
+ const transitioned = deps.assets.transition(entry.id, 'expired', { expire_reason: 'expiry-and-data-change' });
160
+ if (transitioned.persisted) {
161
+ const asset = deps.assets.get(entry.id);
162
+ if (asset !== undefined) deps.evoLedger.recordGovernanceAudit(expireAuditEntry(asset, '候选到期且数据显著变化,已换新'));
163
+ }
164
+ }
165
+ }
166
+ }
167
+ const perItem = await generateCandidatesFor(deps, { item, snapshot, taken, now, summaries });
168
+ generated.push(...perItem.generated);
169
+ skipped.push(...perItem.skipped);
170
+ }
171
+ return { generated, skipped };
172
+ }
173
+
174
+ // 重新生成候选(人工触发):该身份键所有未处置候选先转 expired,再产新一代;
175
+ // 审计 suggestion-refresh 一条(人工触发即确认)。无当前建议时只清理不产新候选。
176
+ export async function regenerateCandidates(deps, { profileKey, advice, summaries }) {
177
+ const unresolved = deps.assets.findUnresolved(profileKey);
178
+ const expired = [];
179
+ for (const entry of unresolved) {
180
+ const transitioned = deps.assets.transition(entry.id, 'expired', { expire_reason: 'manual-regenerate' });
181
+ if (transitioned.persisted) expired.push(entry.id);
182
+ }
183
+ const result = await generateCandidates(deps, { advice, summaries, forceProfileKey: profileKey });
184
+ const generatedIds = Array.isArray(result.generated) ? result.generated : [];
185
+ const item = (Array.isArray(advice) ? advice : []).find((a) => a !== null && typeof a === 'object' && a.profileKey === profileKey);
186
+ const l2 = summaries !== null && typeof summaries === 'object' ? summaries.l2 : undefined;
187
+ const first = generatedIds.length > 0 ? deps.assets.get(generatedIds[0]) : undefined;
188
+ deps.evoLedger.recordGovernanceAudit(refreshAuditEntry({
189
+ profileKey,
190
+ from: sourceConfigFromKey(profileKey, l2),
191
+ to: first !== undefined ? first.to : null,
192
+ axis: item?.suggestion === '升' ? 'budget' : 'capability',
193
+ direction: item?.suggestion === '升' ? 'up' : 'down',
194
+ }));
195
+ return { expired, generated: generatedIds, skipped: Array.isArray(result.skipped) ? result.skipped : [] };
196
+ }
@@ -2,6 +2,9 @@
2
2
  // 只 appendFileSync 追加一行,绝不改写既有行。import-free(仅 node 内置
3
3
  // fs/crypto/path + lib/core/pure.mjs),可被 bare-CI 单测直接 import。采集异常只
4
4
  // warn + 丢失计数,绝不阻断派发(fail-soft)。
5
+ // 轮转:追加成功后在写路径惰性检查阈值(记录数/字节数),命中即封存当前台账为
6
+ // dispatch-<seq>-<ts>.jsonl 并新建空台账续写;封存件超保留上限时淘汰最早者并
7
+ // 提示回放窗口缩短。轮转失败只 warn(本条已落盘),下次写入重试。
5
8
  //
6
9
  // 审计分级:每类写失败分别计数,计数与 health 落盘到同目录的 ledger.meta.json
7
10
  // ({v:1, lostTelemetry, lostGovernance, health})。派发台账写失败计 lostTelemetry
@@ -17,10 +20,11 @@
17
20
  // 不入库(写入那个时刻它们尚不存在,事后回填会破坏 append-only 不可变性,只由聚合层
18
21
  // 惰性 join)。
19
22
 
20
- import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
23
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
21
24
  import { createHash } from 'node:crypto';
22
25
  import { join } from 'node:path';
23
26
  import { textFrom } from './pure.mjs';
27
+ import { createRotation } from './evolution-persistence.mjs';
24
28
 
25
29
  const LEDGER_VERSION = 1;
26
30
  const GOVERNANCE_AUDIT_VERSION = 1;
@@ -88,6 +92,8 @@ function buildBase({ parent, args, merged, mode, pluginVersion }) {
88
92
  // session_id 空值归一 null:它是未来聚合层惰性 join 的键,静默缺失会破坏
89
93
  // join 且难察觉,显式 null 至少保持 JSONL 行形状稳定。
90
94
  session_id: parent?.session?.header?.id ?? null,
95
+ parent_model: parent?.options?.model ?? null,
96
+ parent_provider: parent?.options?.provider ?? null,
91
97
  mode,
92
98
  origin: ORIGIN,
93
99
  requested: cfgOf(merged),
@@ -107,7 +113,7 @@ function buildBase({ parent, args, merged, mode, pluginVersion }) {
107
113
  // tool_count 为「携带 usage 的 assistant/message 事件数」(collectChildCalls 的
108
114
  // totalCalls),是工具调用次数的可测代理而非字面计数值——宿主会话面无更直接的
109
115
  // 工具调用计数字段;拿不到记 null。
110
- function foregroundOutcome({ result, elapsedMs, prunedOutputLen, toolCount }) {
116
+ function foregroundOutcome({ result, elapsedMs, prunedOutputLen, toolCount, childTotalTokens, childUsage }) {
111
117
  const stopReason = result?.stopReason ?? 'completed';
112
118
  const completed = stopReason === 'completed';
113
119
  return {
@@ -116,6 +122,8 @@ function foregroundOutcome({ result, elapsedMs, prunedOutputLen, toolCount }) {
116
122
  elapsed_ms: elapsedMs,
117
123
  output_len: prunedOutputLen,
118
124
  tool_count: completed ? toolCount : null,
125
+ ...(typeof childTotalTokens === 'number' && Number.isFinite(childTotalTokens) ? { tokens: childTotalTokens } : {}),
126
+ ...(childUsage !== null && typeof childUsage === 'object' ? { usage: childUsage } : {}),
119
127
  };
120
128
  }
121
129
 
@@ -130,12 +138,14 @@ function backgroundOutcome(settled) {
130
138
  elapsed_ms: s.elapsedMs,
131
139
  output_len: typeof s.output === 'string' ? s.output.length : 0,
132
140
  tool_count: totalCalls,
141
+ ...(typeof s.childTotalTokens === 'number' && Number.isFinite(s.childTotalTokens) ? { tokens: s.childTotalTokens } : {}),
142
+ ...(s.childUsage !== null && typeof s.childUsage === 'object' ? { usage: s.childUsage } : {}),
133
143
  };
134
144
  }
135
145
 
136
146
  // --- 三分支 finish 助手:每条派发只加一行调用(守 dispatch-tool 行门)------------
137
147
 
138
- export function finishForegroundLedger(ledger, base, childId, result, prunedBlocks, elapsedMs, childCalls) {
148
+ export function finishForegroundLedger(ledger, base, childId, result, prunedBlocks, elapsedMs, childCalls, childTotalTokens, childUsage) {
139
149
  const calls = childCalls ?? {};
140
150
  ledger.record({
141
151
  ...base,
@@ -146,6 +156,8 @@ export function finishForegroundLedger(ledger, base, childId, result, prunedBloc
146
156
  elapsedMs,
147
157
  prunedOutputLen: textFrom(prunedBlocks).length,
148
158
  toolCount: typeof calls.totalCalls === 'number' ? calls.totalCalls : null,
159
+ childTotalTokens,
160
+ childUsage,
149
161
  }),
150
162
  });
151
163
  }
@@ -161,11 +173,26 @@ export function finishContinuableLedger(ledger, base, childId) {
161
173
  // --- 审计 meta(ledger.meta.json)----------------------------------------------
162
174
 
163
175
  // meta 默认态(缺失/损坏/形状不符均回退此值 → fail-soft 从零)。last_write_ts 为
164
- // 最近一次成功落盘时间(§12.4 字段;旧文件无该字段时经合并回退 0)。
165
- const META_DEFAULTS = { v: 1, lostTelemetry: 0, lostGovernance: 0, health: 'ok', last_write_ts: 0 };
176
+ // 最近一次成功落盘时间(旧文件无该字段时经合并回退 0)。lost_count 计版本迁移
177
+ // 类丢失;rotation_seq 为轮转序号(0 = 尚未轮转)。
178
+ const META_DEFAULTS = {
179
+ v: 1,
180
+ lostTelemetry: 0,
181
+ lostGovernance: 0,
182
+ health: 'ok',
183
+ last_write_ts: 0,
184
+ lost_count: 0,
185
+ rotation_seq: 0,
186
+ };
187
+
188
+ // 可选字段(last_write_ts/lost_count/rotation_seq)缺省或非数字时回退默认,
189
+ // 保证旧版本写出的 meta 文件可读。
190
+ function optionalNumber(value, fallback) {
191
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
192
+ }
166
193
 
167
194
  // 读取并校验 meta.json;缺失/损坏/形状不符回退默认并 warn(fail-soft 从零)。
168
- // 只接受 v:1 与必填三字段的数字/字符串形态,防止被手改的坏元数据冒充。
195
+ // 只接受 v:1 与必填字段的数字/字符串形态,防止被手改的坏元数据冒充。
169
196
  function loadAuditMeta(metaFile, warn) {
170
197
  if (!existsSync(metaFile)) return { ...META_DEFAULTS };
171
198
  try {
@@ -176,7 +203,13 @@ function loadAuditMeta(metaFile, warn) {
176
203
  && typeof parsed.lostGovernance === 'number' && Number.isFinite(parsed.lostGovernance)
177
204
  && (parsed.health === 'ok' || parsed.health === 'degraded')
178
205
  ) {
179
- return { ...META_DEFAULTS, ...parsed };
206
+ return {
207
+ ...META_DEFAULTS,
208
+ ...parsed,
209
+ last_write_ts: optionalNumber(parsed.last_write_ts, 0),
210
+ lost_count: optionalNumber(parsed.lost_count, 0),
211
+ rotation_seq: optionalNumber(parsed.rotation_seq, 0),
212
+ };
180
213
  }
181
214
  warn(`ledger meta: 审计元数据形状不符,已从零重建(${metaFile})`);
182
215
  return { ...META_DEFAULTS };
@@ -193,7 +226,15 @@ function loadAuditMeta(metaFile, warn) {
193
226
  // fail-soft:清理 tmp + warn、返回 false。warnOnFailure 供派发遥测失败路径关闭——
194
227
  // 该路径已就台账写失败 warn 一次,避免重复告警。
195
228
  function persistAuditMeta(metaState, healthValue, warn, { warnOnFailure = true } = {}) {
196
- const payload = { v: 1, lostTelemetry: metaState.lost, lostGovernance: metaState.governanceLost, health: healthValue, last_write_ts: Date.now() };
229
+ const payload = {
230
+ v: 1,
231
+ lostTelemetry: metaState.lost,
232
+ lostGovernance: metaState.governanceLost,
233
+ health: healthValue,
234
+ last_write_ts: Date.now(),
235
+ lost_count: metaState.migrationLost,
236
+ rotation_seq: metaState.rotationSeq,
237
+ };
197
238
  let reported = false;
198
239
  try {
199
240
  mkdirSync(metaState.dir, { recursive: true });
@@ -215,16 +256,19 @@ function persistAuditMeta(metaState, healthValue, warn, { warnOnFailure = true }
215
256
 
216
257
  // 治理失败统一接线:计 lostGovernance + health 置 degraded,meta 尽力落盘保证
217
258
  // 重启仍 degraded;内存 health 无论落盘成败一律降级(写失败只 warn,不降回 ok)。
218
- function markGovernanceFailureMeta(meta, persistMeta) {
259
+ // ok → degraded 转变时经 onAlert 通知提醒系统(全局审计异常源)。
260
+ function markGovernanceFailureMeta(meta, persistMeta, onAlert) {
261
+ const wasOk = meta.health === 'ok';
219
262
  meta.governanceLost += 1;
220
263
  meta.health = 'degraded';
221
264
  persistMeta('degraded');
265
+ if (wasOk && typeof onAlert === 'function') onAlert(meta.governanceLost);
222
266
  }
223
267
 
224
268
  // 治理审计事件(放行审计等)追加一行到 governance-audit.jsonl(append-only 不可变)。
225
269
  // 写失败绝不静默:warn + markGovernanceFailureMeta(计 lostGovernance + degraded);
226
270
  // 写成功若正处 degraded,即为一次成功文件写 → 恢复 ok(与派发台账同口径)。
227
- function appendGovernanceAudit(meta, dir, auditFile, persistMeta, warn, entry) {
271
+ function appendGovernanceAudit(meta, dir, auditFile, persistMeta, warn, onAlert, entry) {
228
272
  const line = JSON.stringify({ v: GOVERNANCE_AUDIT_VERSION, ts: Date.now(), ...entry });
229
273
  try {
230
274
  mkdirSync(dir, { recursive: true });
@@ -232,58 +276,130 @@ function appendGovernanceAudit(meta, dir, auditFile, persistMeta, warn, entry) {
232
276
  if (meta.health === 'degraded') persistMeta('ok');
233
277
  } catch (error) {
234
278
  warn(`dispatch ledger: 治理审计事件写入失败(${error instanceof Error ? error.message : String(error)})`);
235
- markGovernanceFailureMeta(meta, persistMeta);
279
+ markGovernanceFailureMeta(meta, persistMeta, onAlert);
280
+ }
281
+ }
282
+
283
+ // 台账现有行数(按换行符计数,文件缺失/读失败回退 0):轮转的记录数判据在
284
+ // 内存累计,避免每次追加都读整文件。
285
+ function countLines(file) {
286
+ try {
287
+ if (!existsSync(file)) return 0;
288
+ const text = readFileSync(file, 'utf8');
289
+ let count = 0;
290
+ for (let i = 0; i < text.length; i += 1) if (text[i] === '\n') count += 1;
291
+ return count;
292
+ } catch {
293
+ return 0;
294
+ }
295
+ }
296
+
297
+ // 台账字节数(文件缺失/读失败回退 0)。
298
+ function fileBytes(file) {
299
+ try {
300
+ return statSync(file).size;
301
+ } catch {
302
+ return 0;
236
303
  }
237
304
  }
238
305
 
239
- export function createEvolutionLedger({ dshHome, pluginVersion, warn = () => {} } = {}) {
306
+ // 轮转执行器装配:追加成功后惰性检查;首次轮转前若 meta 文件缺失,先按现值落盘
307
+ // (rotation_seq 从 0 起步),seq 递增后再落盘。文件操作异常向上抛,由调用方
308
+ // fail-soft 处理。
309
+ function makeRotationCheck({ dir, file, metaFile, meta, persistMeta, warn, rotation }) {
310
+ return createRotation({
311
+ dir,
312
+ dispatchFile: file,
313
+ getRotationSeq: () => meta.rotationSeq,
314
+ setRotationSeq: (seq) => { meta.rotationSeq = seq; },
315
+ initMetaIfMissing: () => {
316
+ if (!existsSync(metaFile)) persistMeta(meta.health, { warnOnFailure: false });
317
+ },
318
+ persistMeta: () => persistMeta(meta.health),
319
+ warn,
320
+ maxRecords: rotation.maxRecords,
321
+ maxBytes: rotation.maxBytes,
322
+ keep: rotation.keep,
323
+ now: rotation.now,
324
+ });
325
+ }
326
+
327
+ // 追加不可变:mkdir recursive + appendFileSync。写失败只 warn + 丢失计数 ++,
328
+ // 绝不抛错、绝不阻断派发。写失败计 lostTelemetry 并落盘 meta;写成功恢复 ok。
329
+ // 轮转检查独立于追加成败:轮转失败只 warn,本条已落盘、下次写入重试。
330
+ function makeRecord({ dir, file, meta, persistMeta, warn, checkRotation }) {
331
+ return (entry) => {
332
+ try {
333
+ // 序列化也在 try 内(循环引用/BigInt 等理论异常会绕过 fail-soft 外泄;
334
+ // 实际 entry 为自产标量,防御性收口)。
335
+ const line = JSON.stringify({ v: LEDGER_VERSION, ts: Date.now(), ...entry });
336
+ mkdirSync(dir, { recursive: true });
337
+ appendFileSync(file, `${line}\n`, 'utf8');
338
+ meta.lineCount += 1;
339
+ if (meta.health === 'degraded') persistMeta('ok');
340
+ } catch (error) {
341
+ meta.lost += 1;
342
+ warn(`dispatch ledger: 派发台账写入失败,已丢失 ${meta.lost} 条(${error instanceof Error ? error.message : String(error)})`);
343
+ persistMeta(meta.health, { warnOnFailure: false });
344
+ return;
345
+ }
346
+ try {
347
+ const result = checkRotation(meta.lineCount, fileBytes(file));
348
+ if (result.rotated) meta.lineCount = 0;
349
+ } catch (error) {
350
+ warn(`dispatch ledger: 台账轮转失败,将在下次写入重试(${error instanceof Error ? error.message : String(error)})`);
351
+ }
352
+ };
353
+ }
354
+
355
+ export function createEvolutionLedger({ dshHome, pluginVersion, warn = () => {}, onAlert, rotation = {} } = {}) {
240
356
  if (typeof dshHome !== 'string' || dshHome.length === 0) {
241
357
  throw new Error('dispatch ledger: 派发台账需要 dshHome 目录');
242
358
  }
243
359
  const dir = join(dshHome, 'subagent-evolution');
244
360
  const file = join(dir, 'dispatch.jsonl');
245
361
  const auditFile = join(dir, 'governance-audit.jsonl');
246
- const persisted = loadAuditMeta(join(dir, 'ledger.meta.json'), warn);
247
- // 可写审计状态:lost(遥测)/governanceLost/health,初始为持久化值。
362
+ const metaFile = join(dir, 'ledger.meta.json');
363
+ const persisted = loadAuditMeta(metaFile, warn);
364
+ // 可写审计状态:lost(遥测)/governanceLost/migrationLost/rotationSeq/health,
365
+ // 初始为持久化值;lineCount 为内存行数累计(构造时读文件初始化)。
248
366
  const meta = {
249
367
  dir,
250
- metaFile: join(dir, 'ledger.meta.json'),
368
+ metaFile,
251
369
  lost: persisted.lostTelemetry,
252
370
  governanceLost: persisted.lostGovernance,
371
+ migrationLost: persisted.lost_count,
372
+ rotationSeq: persisted.rotation_seq,
253
373
  health: persisted.health,
374
+ lineCount: countLines(file),
254
375
  };
255
376
  const persistMeta = (healthValue, opts) => persistAuditMeta(meta, healthValue, warn, opts);
377
+ const checkRotation = makeRotationCheck({ dir, file, metaFile, meta, persistMeta, warn, rotation });
256
378
 
257
379
  return {
258
380
  baseEntry: (input) => buildBase({ ...input, pluginVersion }),
259
- // 追加不可变:mkdir recursive + appendFileSync。写失败只 warn + 丢失计数 ++,
260
- // 绝不抛错、绝不阻断派发。写失败计 lostTelemetry 并落盘 meta;写成功恢复 ok。
261
- record(entry) {
262
- try {
263
- // 序列化也在 try 内(循环引用/BigInt 等理论异常会绕过 fail-soft 外泄;
264
- // 实际 entry 为自产标量,防御性收口)。
265
- const line = JSON.stringify({ v: LEDGER_VERSION, ts: Date.now(), ...entry });
266
- mkdirSync(dir, { recursive: true });
267
- appendFileSync(file, `${line}\n`, 'utf8');
268
- if (meta.health === 'degraded') persistMeta('ok');
269
- } catch (error) {
270
- meta.lost += 1;
271
- warn(`dispatch ledger: 派发台账写入失败,已丢失 ${meta.lost} 条(${error instanceof Error ? error.message : String(error)})`);
272
- persistMeta(meta.health, { warnOnFailure: false });
273
- }
274
- },
381
+ record: makeRecord({ dir, file, meta, persistMeta, warn, checkRotation }),
275
382
  recordGovernanceAudit(entry) {
276
- appendGovernanceAudit(meta, dir, auditFile, persistMeta, warn, entry);
383
+ appendGovernanceAudit(meta, dir, auditFile, persistMeta, warn, onAlert, entry);
277
384
  },
278
385
  // 治理审计失败(profile 变更写失败等):计 lostGovernance + health 置 degraded,
279
386
  // 不抛、不阻断调用方;meta 尽力落盘保证重启仍 degraded。
280
387
  markGovernanceFailure() {
281
- markGovernanceFailureMeta(meta, persistMeta);
388
+ markGovernanceFailureMeta(meta, persistMeta, onAlert);
389
+ },
390
+ // 版本迁移类丢失计数:资产加载方在跳过不可迁移资产时调用(fail-soft 留痕,
391
+ // 不降级 health)。计数随 meta 落盘,重启保持。
392
+ markMigrationLoss() {
393
+ meta.migrationLost += 1;
394
+ persistMeta(meta.health, { warnOnFailure: false });
282
395
  },
283
396
  // 进程内 health('ok' | 'degraded')。
284
397
  metaHealth: () => meta.health,
285
398
  // 设置页 summary 的 audit 字段来源:{lostTelemetry, lostGovernance, health}。
286
399
  auditState: () => ({ lostTelemetry: meta.lost, lostGovernance: meta.governanceLost, health: meta.health }),
287
400
  lostCount: () => meta.lost,
401
+ // 迁移类丢失计数与当前轮转序号(诊断/测试读取)。
402
+ migrationLostCount: () => meta.migrationLost,
403
+ rotationSeq: () => meta.rotationSeq,
288
404
  };
289
405
  }