dsh-subagent-profile 0.3.4 → 0.5.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.
- package/README.md +13 -8
- package/README.zh.md +13 -8
- package/index.mjs +99 -95
- package/lib/client.js +1288 -189
- package/lib/core/decision-trace.mjs +20 -1
- package/lib/core/delegation.mjs +3 -2
- package/lib/core/dispatch-gates.mjs +122 -9
- package/lib/core/dispatch-schema.mjs +45 -7
- package/lib/core/dispatch-tool.mjs +24 -25
- package/lib/core/evolution-advice.mjs +154 -34
- package/lib/core/evolution-assets.mjs +167 -0
- package/lib/core/evolution-canary.mjs +427 -0
- package/lib/core/evolution-draft.mjs +333 -0
- package/lib/core/evolution-engine.mjs +413 -0
- package/lib/core/evolution-generate.mjs +180 -0
- package/lib/core/evolution-lag.mjs +78 -0
- package/lib/core/evolution-ledger.mjs +135 -27
- package/lib/core/evolution-persistence.mjs +213 -0
- package/lib/core/evolution-renewal.mjs +64 -0
- package/lib/core/evolution-routes.mjs +234 -0
- package/lib/core/http-helpers.mjs +35 -0
- package/lib/core/http-routes.mjs +35 -39
- package/lib/core/model-policy.mjs +77 -0
- package/lib/core/presets-sync.mjs +24 -2
- package/lib/core/profile-directory.mjs +17 -0
- package/lib/core/profile-provider.mjs +9 -3
- package/lib/core/profiles-query.mjs +178 -0
- package/lib/core/profiles-store.mjs +56 -8
- package/lib/core/pure.mjs +1 -1
- package/lib/core/session-read.mjs +17 -0
- package/lib/core/shims.mjs +2 -1
- package/package.json +2 -2
- package/presets/orchestrator-v2/NOTICE +7 -0
- package/presets/{orchestrator → orchestrator-v2}/agent.cordis.yml +78 -7
- package/presets/orchestrator-v2/custom-bash.mjs +213 -0
- package/presets/orchestrator-v2/preset.yml +2 -0
- package/presets/orchestrator-v2/tool-bootstrap.mjs +620 -0
- package/presets/orchestrator/preset.yml +0 -2
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// lib/core/evolution-lag.mjs — 防振荡域(滞后计数 + 回滚冷却 + 观察期联动判据)。
|
|
2
|
+
// 滞后 N:同 (profile_key, axis, direction) 连续 N 次同方向建议才放行候选生成
|
|
3
|
+
// (N=2 初值,待实测后校准);与建议链 10min cooldown 并存——cooldown 优先抑制,
|
|
4
|
+
// 被抑制的建议不计入连续次数。回滚后冷却:rolled_back 资产进同键冷却,冷却期内
|
|
5
|
+
// 不再产生同方向候选(闭环有界:同键 apply→rollback 往返最多 1 次进入冷却)。
|
|
6
|
+
// 观察期联动:观察期内(proposed + 金丝雀未终)同键禁止换代与重生成候选;无
|
|
7
|
+
// canary 字段的旧资产不阻断(保持既有「重新生成候选」通道)。
|
|
8
|
+
// 本域纯函数 + 注入保存:streak 缓存驻内存,落盘由装配层注入 save(fail-soft)。
|
|
9
|
+
|
|
10
|
+
// 滞后 N:连续同方向建议次数门槛(初值,待实测后校准)。
|
|
11
|
+
export const LAG_N = 2;
|
|
12
|
+
// 回滚后同键冷却时长(与建议链 cooldown 同值)。
|
|
13
|
+
export const ROLLBACK_COOLDOWN_MS = 10 * 60 * 1000;
|
|
14
|
+
|
|
15
|
+
// 建议方向 → 资产方向(down/up 与候选资产同口径)。
|
|
16
|
+
export function directionOf(item) {
|
|
17
|
+
return item !== null && typeof item === 'object' && item.suggestion === '升' ? 'up' : 'down';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 建议方向 → 资产轴(capability/budget)。
|
|
21
|
+
export function axisOf(item) {
|
|
22
|
+
return item !== null && typeof item === 'object' && item.suggestion === '升' ? 'budget' : 'capability';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 观察期是否进行中:proposed + 金丝雀未终且未因人工编辑失效。verdict 为 applied /
|
|
26
|
+
// aborted-human-edit 或资产 invalid 时视为已终;regress 等待人工回滚期间仍阻断
|
|
27
|
+
// (换代会销毁回滚建议依据,须先回滚或人工编辑)。
|
|
28
|
+
export function observationPeriodActive(asset) {
|
|
29
|
+
if (asset === null || typeof asset !== 'object' || asset.state !== 'proposed' || asset.invalid === true) return false;
|
|
30
|
+
const canary = asset.canary;
|
|
31
|
+
if (canary === null || typeof canary !== 'object') return false;
|
|
32
|
+
return canary.verdict !== 'applied' && canary.verdict !== 'aborted-human-edit';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 资产列表(完整资产)中是否存在同键观察期资产。
|
|
36
|
+
export function observationPeriodBlocked(assetList, profileKey) {
|
|
37
|
+
return assetList.some((a) => a !== null && typeof a === 'object' && a.profile_key === profileKey && observationPeriodActive(a));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 决议后冷却是否生效:同键同方向 rolled_back 资产(回滚决议)或带 cooldown_until
|
|
41
|
+
// 的 applied 资产(显式保留决议)在冷却期内抑制候选生成——两种决议都是闭环终点,
|
|
42
|
+
// 冷却口径一致(有界性:同键 apply→rollback 往返最多 1 次进入冷却)。
|
|
43
|
+
export function rollbackCooldownBlocks(assetList, profileKey, direction, now, cooldownMs = ROLLBACK_COOLDOWN_MS) {
|
|
44
|
+
return assetList.some((a) => a !== null && typeof a === 'object' && a.profile_key === profileKey && a.direction === direction
|
|
45
|
+
&& ((a.state === 'rolled_back' && Number.isFinite(a.rollback_at) && now - a.rollback_at < cooldownMs)
|
|
46
|
+
|| (a.state === 'applied' && Number.isFinite(a.cooldown_until) && now < a.cooldown_until)));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// streak 键:身份键 + 轴 + 方向(连续同方向计数按此聚合)。
|
|
50
|
+
function streakKeyOf(profileKey, axis, direction) {
|
|
51
|
+
return `${profileKey}\u0000${axis}\u0000${direction}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 连续同方向建议计数:缓存未命中时读 summaries 里上次落盘的 streak(重启续数),
|
|
55
|
+
// 方向翻转重置为 1;计数封顶 LAG_N(防无限增长)。save 注入为批量落盘(fail-soft)。
|
|
56
|
+
export function bumpStreak(streaks, item, summaries, now) {
|
|
57
|
+
const profileKey = item.profileKey;
|
|
58
|
+
const axis = axisOf(item);
|
|
59
|
+
const direction = directionOf(item);
|
|
60
|
+
const key = streakKeyOf(profileKey, axis, direction);
|
|
61
|
+
let prev = streaks.cache.get(key);
|
|
62
|
+
if (prev === undefined) {
|
|
63
|
+
const l1 = summaries !== null && typeof summaries === 'object' && summaries.l1 !== null && typeof summaries.l1 === 'object' ? summaries.l1 : {};
|
|
64
|
+
const seeded = l1[profileKey]?.streak;
|
|
65
|
+
prev = seeded !== null && typeof seeded === 'object' && seeded.axis === axis && seeded.direction === direction ? seeded : null;
|
|
66
|
+
}
|
|
67
|
+
const count = prev !== null && typeof prev === 'object' && Number.isFinite(prev.count) ? Math.min(LAG_N, prev.count + 1) : 1;
|
|
68
|
+
const next = { profileKey, axis, direction, count, last_ts: now };
|
|
69
|
+
streaks.cache.set(key, next);
|
|
70
|
+
try { streaks.save([next]); } catch { /* 落盘失败不阻断生成(fail-soft) */ }
|
|
71
|
+
return count;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// streak 缓存工厂:save 注入落盘(装配层写回 summaries.json);缓存驻内存供
|
|
75
|
+
// 同进程连续轮次复用。
|
|
76
|
+
export function createLagStreaks({ save = () => {} } = {}) {
|
|
77
|
+
return { cache: new Map(), save };
|
|
78
|
+
}
|
|
@@ -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;
|
|
@@ -78,7 +82,7 @@ function cfgOf(merged, opts = {}) {
|
|
|
78
82
|
|
|
79
83
|
// 记录静态元数据(写入即知的共享字段,不含 ts/v——由 record 落盘时补)。三分支各自
|
|
80
84
|
// 在此基础上补 child_id 与 outcome 后调用 record。pluginVersion 由工厂闭包注入。
|
|
81
|
-
function buildBase({ parent, args, merged, mode, pluginVersion }) {
|
|
85
|
+
function buildBase({ parent, args, merged, mode, pluginVersion, effectiveModelSource }) {
|
|
82
86
|
const prompt = typeof args?.prompt === 'string' ? args.prompt : '';
|
|
83
87
|
const effectivePreset = mode === 'continuable' ? 'inherit' : (merged?.preset ?? 'inherit');
|
|
84
88
|
// continuable 下宿主忽略 reasoningEffort(决策轨迹侧将其列入 ignored),生效栏
|
|
@@ -90,6 +94,9 @@ function buildBase({ parent, args, merged, mode, pluginVersion }) {
|
|
|
90
94
|
session_id: parent?.session?.header?.id ?? null,
|
|
91
95
|
parent_model: parent?.options?.model ?? null,
|
|
92
96
|
parent_provider: parent?.options?.provider ?? null,
|
|
97
|
+
// 生效来源(call/profile/parent/default):每次派发记录「生效路由是谁决定的」;
|
|
98
|
+
// 调用方未传时归一 null,保持 JSONL 行形状稳定。
|
|
99
|
+
effectiveModelSource: typeof effectiveModelSource === 'string' && effectiveModelSource !== '' ? effectiveModelSource : null,
|
|
93
100
|
mode,
|
|
94
101
|
origin: ORIGIN,
|
|
95
102
|
requested: cfgOf(merged),
|
|
@@ -169,11 +176,26 @@ export function finishContinuableLedger(ledger, base, childId) {
|
|
|
169
176
|
// --- 审计 meta(ledger.meta.json)----------------------------------------------
|
|
170
177
|
|
|
171
178
|
// meta 默认态(缺失/损坏/形状不符均回退此值 → fail-soft 从零)。last_write_ts 为
|
|
172
|
-
// 最近一次成功落盘时间(旧文件无该字段时经合并回退 0)。
|
|
173
|
-
|
|
179
|
+
// 最近一次成功落盘时间(旧文件无该字段时经合并回退 0)。lost_count 计版本迁移
|
|
180
|
+
// 类丢失;rotation_seq 为轮转序号(0 = 尚未轮转)。
|
|
181
|
+
const META_DEFAULTS = {
|
|
182
|
+
v: 1,
|
|
183
|
+
lostTelemetry: 0,
|
|
184
|
+
lostGovernance: 0,
|
|
185
|
+
health: 'ok',
|
|
186
|
+
last_write_ts: 0,
|
|
187
|
+
lost_count: 0,
|
|
188
|
+
rotation_seq: 0,
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// 可选字段(last_write_ts/lost_count/rotation_seq)缺省或非数字时回退默认,
|
|
192
|
+
// 保证旧版本写出的 meta 文件可读。
|
|
193
|
+
function optionalNumber(value, fallback) {
|
|
194
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
195
|
+
}
|
|
174
196
|
|
|
175
197
|
// 读取并校验 meta.json;缺失/损坏/形状不符回退默认并 warn(fail-soft 从零)。
|
|
176
|
-
// 只接受 v:1
|
|
198
|
+
// 只接受 v:1 与必填字段的数字/字符串形态,防止被手改的坏元数据冒充。
|
|
177
199
|
function loadAuditMeta(metaFile, warn) {
|
|
178
200
|
if (!existsSync(metaFile)) return { ...META_DEFAULTS };
|
|
179
201
|
try {
|
|
@@ -184,7 +206,13 @@ function loadAuditMeta(metaFile, warn) {
|
|
|
184
206
|
&& typeof parsed.lostGovernance === 'number' && Number.isFinite(parsed.lostGovernance)
|
|
185
207
|
&& (parsed.health === 'ok' || parsed.health === 'degraded')
|
|
186
208
|
) {
|
|
187
|
-
return {
|
|
209
|
+
return {
|
|
210
|
+
...META_DEFAULTS,
|
|
211
|
+
...parsed,
|
|
212
|
+
last_write_ts: optionalNumber(parsed.last_write_ts, 0),
|
|
213
|
+
lost_count: optionalNumber(parsed.lost_count, 0),
|
|
214
|
+
rotation_seq: optionalNumber(parsed.rotation_seq, 0),
|
|
215
|
+
};
|
|
188
216
|
}
|
|
189
217
|
warn(`ledger meta: 审计元数据形状不符,已从零重建(${metaFile})`);
|
|
190
218
|
return { ...META_DEFAULTS };
|
|
@@ -201,7 +229,15 @@ function loadAuditMeta(metaFile, warn) {
|
|
|
201
229
|
// fail-soft:清理 tmp + warn、返回 false。warnOnFailure 供派发遥测失败路径关闭——
|
|
202
230
|
// 该路径已就台账写失败 warn 一次,避免重复告警。
|
|
203
231
|
function persistAuditMeta(metaState, healthValue, warn, { warnOnFailure = true } = {}) {
|
|
204
|
-
const payload = {
|
|
232
|
+
const payload = {
|
|
233
|
+
v: 1,
|
|
234
|
+
lostTelemetry: metaState.lost,
|
|
235
|
+
lostGovernance: metaState.governanceLost,
|
|
236
|
+
health: healthValue,
|
|
237
|
+
last_write_ts: Date.now(),
|
|
238
|
+
lost_count: metaState.migrationLost,
|
|
239
|
+
rotation_seq: metaState.rotationSeq,
|
|
240
|
+
};
|
|
205
241
|
let reported = false;
|
|
206
242
|
try {
|
|
207
243
|
mkdirSync(metaState.dir, { recursive: true });
|
|
@@ -247,42 +283,105 @@ function appendGovernanceAudit(meta, dir, auditFile, persistMeta, warn, onAlert,
|
|
|
247
283
|
}
|
|
248
284
|
}
|
|
249
285
|
|
|
250
|
-
|
|
286
|
+
// 台账现有行数(按换行符计数,文件缺失/读失败回退 0):轮转的记录数判据在
|
|
287
|
+
// 内存累计,避免每次追加都读整文件。
|
|
288
|
+
function countLines(file) {
|
|
289
|
+
try {
|
|
290
|
+
if (!existsSync(file)) return 0;
|
|
291
|
+
const text = readFileSync(file, 'utf8');
|
|
292
|
+
let count = 0;
|
|
293
|
+
for (let i = 0; i < text.length; i += 1) if (text[i] === '\n') count += 1;
|
|
294
|
+
return count;
|
|
295
|
+
} catch {
|
|
296
|
+
return 0;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// 台账字节数(文件缺失/读失败回退 0)。
|
|
301
|
+
function fileBytes(file) {
|
|
302
|
+
try {
|
|
303
|
+
return statSync(file).size;
|
|
304
|
+
} catch {
|
|
305
|
+
return 0;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// 轮转执行器装配:追加成功后惰性检查;首次轮转前若 meta 文件缺失,先按现值落盘
|
|
310
|
+
// (rotation_seq 从 0 起步),seq 递增后再落盘。文件操作异常向上抛,由调用方
|
|
311
|
+
// fail-soft 处理。
|
|
312
|
+
function makeRotationCheck({ dir, file, metaFile, meta, persistMeta, warn, rotation }) {
|
|
313
|
+
return createRotation({
|
|
314
|
+
dir,
|
|
315
|
+
dispatchFile: file,
|
|
316
|
+
getRotationSeq: () => meta.rotationSeq,
|
|
317
|
+
setRotationSeq: (seq) => { meta.rotationSeq = seq; },
|
|
318
|
+
initMetaIfMissing: () => {
|
|
319
|
+
if (!existsSync(metaFile)) persistMeta(meta.health, { warnOnFailure: false });
|
|
320
|
+
},
|
|
321
|
+
persistMeta: () => persistMeta(meta.health),
|
|
322
|
+
warn,
|
|
323
|
+
maxRecords: rotation.maxRecords,
|
|
324
|
+
maxBytes: rotation.maxBytes,
|
|
325
|
+
keep: rotation.keep,
|
|
326
|
+
now: rotation.now,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// 追加不可变:mkdir recursive + appendFileSync。写失败只 warn + 丢失计数 ++,
|
|
331
|
+
// 绝不抛错、绝不阻断派发。写失败计 lostTelemetry 并落盘 meta;写成功恢复 ok。
|
|
332
|
+
// 轮转检查独立于追加成败:轮转失败只 warn,本条已落盘、下次写入重试。
|
|
333
|
+
function makeRecord({ dir, file, meta, persistMeta, warn, checkRotation }) {
|
|
334
|
+
return (entry) => {
|
|
335
|
+
try {
|
|
336
|
+
// 序列化也在 try 内(循环引用/BigInt 等理论异常会绕过 fail-soft 外泄;
|
|
337
|
+
// 实际 entry 为自产标量,防御性收口)。
|
|
338
|
+
const line = JSON.stringify({ v: LEDGER_VERSION, ts: Date.now(), ...entry });
|
|
339
|
+
mkdirSync(dir, { recursive: true });
|
|
340
|
+
appendFileSync(file, `${line}\n`, 'utf8');
|
|
341
|
+
meta.lineCount += 1;
|
|
342
|
+
if (meta.health === 'degraded') persistMeta('ok');
|
|
343
|
+
} catch (error) {
|
|
344
|
+
meta.lost += 1;
|
|
345
|
+
warn(`dispatch ledger: 派发台账写入失败,已丢失 ${meta.lost} 条(${error instanceof Error ? error.message : String(error)})`);
|
|
346
|
+
persistMeta(meta.health, { warnOnFailure: false });
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
const result = checkRotation(meta.lineCount, fileBytes(file));
|
|
351
|
+
if (result.rotated) meta.lineCount = 0;
|
|
352
|
+
} catch (error) {
|
|
353
|
+
warn(`dispatch ledger: 台账轮转失败,将在下次写入重试(${error instanceof Error ? error.message : String(error)})`);
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function createEvolutionLedger({ dshHome, pluginVersion, warn = () => {}, onAlert, rotation = {} } = {}) {
|
|
251
359
|
if (typeof dshHome !== 'string' || dshHome.length === 0) {
|
|
252
360
|
throw new Error('dispatch ledger: 派发台账需要 dshHome 目录');
|
|
253
361
|
}
|
|
254
362
|
const dir = join(dshHome, 'subagent-evolution');
|
|
255
363
|
const file = join(dir, 'dispatch.jsonl');
|
|
256
364
|
const auditFile = join(dir, 'governance-audit.jsonl');
|
|
257
|
-
const
|
|
258
|
-
|
|
365
|
+
const metaFile = join(dir, 'ledger.meta.json');
|
|
366
|
+
const persisted = loadAuditMeta(metaFile, warn);
|
|
367
|
+
// 可写审计状态:lost(遥测)/governanceLost/migrationLost/rotationSeq/health,
|
|
368
|
+
// 初始为持久化值;lineCount 为内存行数累计(构造时读文件初始化)。
|
|
259
369
|
const meta = {
|
|
260
370
|
dir,
|
|
261
|
-
metaFile
|
|
371
|
+
metaFile,
|
|
262
372
|
lost: persisted.lostTelemetry,
|
|
263
373
|
governanceLost: persisted.lostGovernance,
|
|
374
|
+
migrationLost: persisted.lost_count,
|
|
375
|
+
rotationSeq: persisted.rotation_seq,
|
|
264
376
|
health: persisted.health,
|
|
377
|
+
lineCount: countLines(file),
|
|
265
378
|
};
|
|
266
379
|
const persistMeta = (healthValue, opts) => persistAuditMeta(meta, healthValue, warn, opts);
|
|
380
|
+
const checkRotation = makeRotationCheck({ dir, file, metaFile, meta, persistMeta, warn, rotation });
|
|
267
381
|
|
|
268
382
|
return {
|
|
269
383
|
baseEntry: (input) => buildBase({ ...input, pluginVersion }),
|
|
270
|
-
|
|
271
|
-
// 绝不抛错、绝不阻断派发。写失败计 lostTelemetry 并落盘 meta;写成功恢复 ok。
|
|
272
|
-
record(entry) {
|
|
273
|
-
try {
|
|
274
|
-
// 序列化也在 try 内(循环引用/BigInt 等理论异常会绕过 fail-soft 外泄;
|
|
275
|
-
// 实际 entry 为自产标量,防御性收口)。
|
|
276
|
-
const line = JSON.stringify({ v: LEDGER_VERSION, ts: Date.now(), ...entry });
|
|
277
|
-
mkdirSync(dir, { recursive: true });
|
|
278
|
-
appendFileSync(file, `${line}\n`, 'utf8');
|
|
279
|
-
if (meta.health === 'degraded') persistMeta('ok');
|
|
280
|
-
} catch (error) {
|
|
281
|
-
meta.lost += 1;
|
|
282
|
-
warn(`dispatch ledger: 派发台账写入失败,已丢失 ${meta.lost} 条(${error instanceof Error ? error.message : String(error)})`);
|
|
283
|
-
persistMeta(meta.health, { warnOnFailure: false });
|
|
284
|
-
}
|
|
285
|
-
},
|
|
384
|
+
record: makeRecord({ dir, file, meta, persistMeta, warn, checkRotation }),
|
|
286
385
|
recordGovernanceAudit(entry) {
|
|
287
386
|
appendGovernanceAudit(meta, dir, auditFile, persistMeta, warn, onAlert, entry);
|
|
288
387
|
},
|
|
@@ -291,10 +390,19 @@ export function createEvolutionLedger({ dshHome, pluginVersion, warn = () => {},
|
|
|
291
390
|
markGovernanceFailure() {
|
|
292
391
|
markGovernanceFailureMeta(meta, persistMeta, onAlert);
|
|
293
392
|
},
|
|
393
|
+
// 版本迁移类丢失计数:资产加载方在跳过不可迁移资产时调用(fail-soft 留痕,
|
|
394
|
+
// 不降级 health)。计数随 meta 落盘,重启保持。
|
|
395
|
+
markMigrationLoss() {
|
|
396
|
+
meta.migrationLost += 1;
|
|
397
|
+
persistMeta(meta.health, { warnOnFailure: false });
|
|
398
|
+
},
|
|
294
399
|
// 进程内 health('ok' | 'degraded')。
|
|
295
400
|
metaHealth: () => meta.health,
|
|
296
401
|
// 设置页 summary 的 audit 字段来源:{lostTelemetry, lostGovernance, health}。
|
|
297
402
|
auditState: () => ({ lostTelemetry: meta.lost, lostGovernance: meta.governanceLost, health: meta.health }),
|
|
298
403
|
lostCount: () => meta.lost,
|
|
404
|
+
// 迁移类丢失计数与当前轮转序号(诊断/测试读取)。
|
|
405
|
+
migrationLostCount: () => meta.migrationLost,
|
|
406
|
+
rotationSeq: () => meta.rotationSeq,
|
|
299
407
|
};
|
|
300
408
|
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// lib/core/evolution-persistence.mjs — 进化资产持久化机制:版本前向迁移与派发台账轮转。
|
|
2
|
+
// 迁移:可重写资产顶层 v 版本号,load 时仅在内存链式前向迁移(v→v+1),迁移完成后
|
|
3
|
+
// 原子写回(tmp+rename);未知/超前版本或缺少迁移步骤一律 fail-soft 跳过并计丢失,
|
|
4
|
+
// 绝不阻断其余功能、绝不改写台账号行。
|
|
5
|
+
// 轮转:dispatch.jsonl 达阈值(记录数或字节数)时封存为 dispatch-<seq>-<ts>.jsonl,
|
|
6
|
+
// 新建空台账续写;封存只 rename + 新建(内容字节不变、封存件不可变);封存件超过
|
|
7
|
+
// 保留份数时按最早封存淘汰,并一次性提示回放窗口缩短。
|
|
8
|
+
// import-free(仅 node 内置),可被 bare-CI 单测直接导入。
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
existsSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
readdirSync,
|
|
15
|
+
renameSync,
|
|
16
|
+
rmSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
} from 'node:fs';
|
|
19
|
+
import { dirname, join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
// --- 版本迁移 ---------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
// 各可重写资产的当前版本号。新增资产类型在此登记。
|
|
24
|
+
export const CURRENT_VERSIONS = Object.freeze({ summaries: 1 });
|
|
25
|
+
|
|
26
|
+
// 前向迁移表:<kind, fromV> → 迁移函数(入参 v=n 的数据,须返回 v=n+1 的数据)。
|
|
27
|
+
// 当前各资产无跨版本 schema 变化,表为空;机制(链式前向迁移 / 未知版本
|
|
28
|
+
// fail-soft / 迁移后写回)由测试注入样例迁移覆盖。出现真实 schema 变化时在此
|
|
29
|
+
// 登记步骤并同步提升 CURRENT_VERSIONS 对应项。
|
|
30
|
+
export const MIGRATIONS = Object.freeze({});
|
|
31
|
+
|
|
32
|
+
// 单次加载允许的最多迁移步数(防迁移表被误配成死循环)。
|
|
33
|
+
const MAX_MIGRATION_STEPS = 100;
|
|
34
|
+
|
|
35
|
+
// 迁移错误:版本非法/超前/缺步骤等,由调用方按 fail-soft 处理。
|
|
36
|
+
export class MigrationError extends Error {}
|
|
37
|
+
|
|
38
|
+
// 内存链式前向迁移:v 小于当前版本时逐级执行迁移表步骤,返回 {data, migrated,
|
|
39
|
+
// fromV, toV};v 等于当前版本原样返回;v 非法/超前或缺步骤抛 MigrationError。
|
|
40
|
+
export function migrateUp(data, kind, { migrations = MIGRATIONS, currentVersions = CURRENT_VERSIONS } = {}) {
|
|
41
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
|
|
42
|
+
throw new MigrationError('数据不是普通对象');
|
|
43
|
+
}
|
|
44
|
+
const current = currentVersions[kind];
|
|
45
|
+
if (typeof current !== 'number' || !Number.isInteger(current)) {
|
|
46
|
+
throw new MigrationError(`未知资产类型:${String(kind)}`);
|
|
47
|
+
}
|
|
48
|
+
const fromV = data.v;
|
|
49
|
+
if (!Number.isInteger(fromV) || fromV < 1) {
|
|
50
|
+
throw new MigrationError(`版本号非法:${String(fromV)}`);
|
|
51
|
+
}
|
|
52
|
+
if (fromV > current) throw new MigrationError(`版本 ${fromV} 超前于当前版本 ${current}`);
|
|
53
|
+
if (fromV === current) return { data, migrated: false, fromV, toV: fromV };
|
|
54
|
+
const steps = (migrations ?? {})[kind] ?? {};
|
|
55
|
+
let cursor = data;
|
|
56
|
+
let v = fromV;
|
|
57
|
+
while (v < current) {
|
|
58
|
+
const step = steps[v];
|
|
59
|
+
if (typeof step !== 'function') {
|
|
60
|
+
throw new MigrationError(`缺少 ${String(kind)} v${v} 到 v${v + 1} 的迁移步骤`);
|
|
61
|
+
}
|
|
62
|
+
cursor = step(cursor);
|
|
63
|
+
if (cursor === null || typeof cursor !== 'object' || Array.isArray(cursor)) {
|
|
64
|
+
throw new MigrationError(`迁移步骤 v${v} 产出非法数据`);
|
|
65
|
+
}
|
|
66
|
+
v += 1;
|
|
67
|
+
if (v - fromV > MAX_MIGRATION_STEPS) throw new MigrationError('迁移步数超过上限');
|
|
68
|
+
}
|
|
69
|
+
// 归一顶层版本号:迁移步骤可省略写 v,此处强制对齐当前版本。
|
|
70
|
+
cursor.v = current;
|
|
71
|
+
return { data: cursor, migrated: true, fromV, toV: current };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 迁移写回(tmp+rename):崩溃不产生截断文件;失败仅告警(内存迁移结果仍可用),
|
|
75
|
+
// 不抛、不重复计丢失。
|
|
76
|
+
function writeBack(file, data, warnLog) {
|
|
77
|
+
const tmp = `${file}.tmp`;
|
|
78
|
+
try {
|
|
79
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
80
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
|
|
81
|
+
renameSync(tmp, file);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
|
|
84
|
+
warnLog(`[dsh-subagent-profile] 资产迁移写回失败(${file}):${error instanceof Error ? error.message : String(error)}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 读取并迁移单个资产文件。缺失返回 null(不告警不计丢失);损坏/形状不符/版本
|
|
89
|
+
// 无法迁移 → 告警 + onLoss + null(fail-soft 跳过);迁移发生则原子写回后再返回。
|
|
90
|
+
// logger 取带 warn 方法的对象(缺省静默)。
|
|
91
|
+
export function loadWithMigration(
|
|
92
|
+
file,
|
|
93
|
+
kind,
|
|
94
|
+
{ logger, migrations = MIGRATIONS, currentVersions = CURRENT_VERSIONS, onLoss } = {}
|
|
95
|
+
) {
|
|
96
|
+
const warnLog = (message) => {
|
|
97
|
+
if (logger !== undefined && logger !== null && typeof logger.warn === 'function') logger.warn(message);
|
|
98
|
+
};
|
|
99
|
+
const lose = () => { if (typeof onLoss === 'function') onLoss(); };
|
|
100
|
+
if (!existsSync(file)) return null;
|
|
101
|
+
let parsed;
|
|
102
|
+
try {
|
|
103
|
+
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
104
|
+
} catch (error) {
|
|
105
|
+
warnLog(`[dsh-subagent-profile] ${String(kind)} 资产损坏,跳过该资产(fail-soft):${error instanceof Error ? error.message : String(error)}`);
|
|
106
|
+
lose();
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
110
|
+
warnLog(`[dsh-subagent-profile] ${String(kind)} 资产形状不符,跳过该资产(fail-soft)`);
|
|
111
|
+
lose();
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
let result;
|
|
115
|
+
try {
|
|
116
|
+
result = migrateUp(parsed, kind, { migrations, currentVersions });
|
|
117
|
+
} catch (error) {
|
|
118
|
+
const version = parsed.v !== undefined ? String(parsed.v) : '未知';
|
|
119
|
+
warnLog(`[dsh-subagent-profile] ${String(kind)} 资产版本 ${version} 无法迁移,跳过该资产(fail-soft):${error instanceof Error ? error.message : String(error)}`);
|
|
120
|
+
lose();
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
if (result.migrated) writeBack(file, result.data, warnLog);
|
|
124
|
+
return result.data;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// --- 台账轮转 ----------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
// 轮转阈值:记录数(>50k)或字节数(>5MB),任一命中即轮转。阈值为建议初值,
|
|
130
|
+
// 待实测后校准。
|
|
131
|
+
export const ROTATION_DEFAULT_MAX_RECORDS = 50000;
|
|
132
|
+
export const ROTATION_DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
|
|
133
|
+
// 封存件保留上限(回放窗口):超过按最早封存淘汰。
|
|
134
|
+
export const ROTATION_DEFAULT_KEEP = 8;
|
|
135
|
+
|
|
136
|
+
// 封存件文件名:dispatch-<seq>-<ts>.jsonl(同 seq 同毫秒碰撞时追加 -<n>)。
|
|
137
|
+
const ARCHIVE_PATTERN = /^dispatch-(\d+)-(\d+)(?:-\d+)?\.jsonl$/;
|
|
138
|
+
|
|
139
|
+
// 是否达轮转阈值:记录数超限或字节数超限(任一命中)。
|
|
140
|
+
export function rotationNeeded(records, bytes, { maxRecords = ROTATION_DEFAULT_MAX_RECORDS, maxBytes = ROTATION_DEFAULT_MAX_BYTES } = {}) {
|
|
141
|
+
return records > maxRecords || bytes > maxBytes;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 目录内封存件清单(按 seq、ts 升序 = 最早封存在前)。目录缺失/读失败返回空表。
|
|
145
|
+
function listArchives(dir) {
|
|
146
|
+
let names;
|
|
147
|
+
try {
|
|
148
|
+
names = readdirSync(dir);
|
|
149
|
+
} catch {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
return names
|
|
153
|
+
.map((name) => ARCHIVE_PATTERN.exec(name))
|
|
154
|
+
.filter((match) => match !== null)
|
|
155
|
+
.map((match) => ({ name: match[0], seq: Number(match[1]), ts: Number(match[2]) }))
|
|
156
|
+
.sort((a, b) => a.seq - b.seq || a.ts - b.ts);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 淘汰最早封存件至保留上限;发生淘汰时一次性提示回放窗口缩短(每次轮转最多一次,
|
|
160
|
+
// 因本函数每次轮转只执行一次)。单个文件删除失败不阻断其余淘汰。
|
|
161
|
+
function pruneArchives(dir, keep, warn) {
|
|
162
|
+
const list = listArchives(dir);
|
|
163
|
+
if (list.length <= keep) return 0;
|
|
164
|
+
const excess = list.slice(0, list.length - keep);
|
|
165
|
+
let removed = 0;
|
|
166
|
+
for (const entry of excess) {
|
|
167
|
+
try {
|
|
168
|
+
rmSync(join(dir, entry.name), { force: true });
|
|
169
|
+
removed += 1;
|
|
170
|
+
} catch { /* 单个封存件删除失败不影响主流程 */ }
|
|
171
|
+
}
|
|
172
|
+
warn(`dispatch ledger: 回放窗口缩短——封存件已超过 ${keep} 份,淘汰最旧 ${removed} 份`);
|
|
173
|
+
return removed;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 创建轮转执行器:check(records, bytes) 在阈值命中时执行一次轮转,返回
|
|
177
|
+
// {rotated, archived, seq, pruned};未命中返回 {rotated:false}。seq 账本与 meta
|
|
178
|
+
// 落盘由调用方注入:getRotationSeq/setRotationSeq 管内存 seq,initMetaIfMissing
|
|
179
|
+
// 保证首次轮转先建 meta(seq 现值落盘),persistMeta 以更新后的 seq 落盘。
|
|
180
|
+
// 文件操作异常向上抛,由调用方按 fail-soft 处理(本条已落盘的追加不受影响)。
|
|
181
|
+
export function createRotation({
|
|
182
|
+
dir,
|
|
183
|
+
dispatchFile,
|
|
184
|
+
getRotationSeq = () => 0,
|
|
185
|
+
setRotationSeq = () => {},
|
|
186
|
+
initMetaIfMissing = () => {},
|
|
187
|
+
persistMeta = () => true,
|
|
188
|
+
warn = () => {},
|
|
189
|
+
maxRecords = ROTATION_DEFAULT_MAX_RECORDS,
|
|
190
|
+
maxBytes = ROTATION_DEFAULT_MAX_BYTES,
|
|
191
|
+
keep = ROTATION_DEFAULT_KEEP,
|
|
192
|
+
now = Date.now,
|
|
193
|
+
} = {}) {
|
|
194
|
+
return (records, bytes) => {
|
|
195
|
+
if (!rotationNeeded(records, bytes, { maxRecords, maxBytes })) return { rotated: false };
|
|
196
|
+
initMetaIfMissing();
|
|
197
|
+
const seq = getRotationSeq() + 1;
|
|
198
|
+
const stamp = now();
|
|
199
|
+
let archive = join(dir, `dispatch-${seq}-${stamp}.jsonl`);
|
|
200
|
+
let attempt = 0;
|
|
201
|
+
while (existsSync(archive)) {
|
|
202
|
+
attempt += 1;
|
|
203
|
+
archive = join(dir, `dispatch-${seq}-${stamp}-${attempt}.jsonl`);
|
|
204
|
+
}
|
|
205
|
+
// 只 rename + 新建:封存内容字节不变,绝不重写任何已写行。
|
|
206
|
+
renameSync(dispatchFile, archive);
|
|
207
|
+
setRotationSeq(seq);
|
|
208
|
+
persistMeta();
|
|
209
|
+
writeFileSync(dispatchFile, '', 'utf8');
|
|
210
|
+
const pruned = pruneArchives(dir, keep, warn);
|
|
211
|
+
return { rotated: true, archived: archive, seq, pruned };
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// lib/core/evolution-renewal.mjs — 候选换代判定与换代审计(从 evolution-engine 拆出
|
|
2
|
+
// 守行门):到期判定、有效期初值、数据指纹、显著变化判定(阈值初值,待实测后校准)、
|
|
3
|
+
// suggestion-expire / suggestion-refresh 审计条目。纯函数、零 IO、零依赖,可裸 import 单测。
|
|
4
|
+
|
|
5
|
+
// 候选到期判定:expiry 为 null(不自动过期)恒不到期,否则 now >= expiry。
|
|
6
|
+
export function candidateExpired(entry, now) {
|
|
7
|
+
return typeof entry.expiry === 'number' && now >= entry.expiry;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// 候选有效期初值:手动维护恒不自动过期;自动换新按设置小时数计算,0 = 不自动过期。
|
|
11
|
+
export function candidateExpiryOf(mode, ttlH, now) {
|
|
12
|
+
if (mode === 'manual') return null;
|
|
13
|
+
return ttlH === 0 ? null : now + ttlH * 3600 * 1000;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// 数据指纹:该身份键当前聚合的样本数/加权成功分/置信度(换代判定输入)。
|
|
17
|
+
export function dataFingerprintOf(item, summaries) {
|
|
18
|
+
const l1 = summaries !== null && typeof summaries === 'object' && summaries.l1 !== null && typeof summaries.l1 === 'object'
|
|
19
|
+
? summaries.l1
|
|
20
|
+
: {};
|
|
21
|
+
const group = typeof item.profileKey === 'string' ? l1[item.profileKey] : undefined;
|
|
22
|
+
const entry = group !== null && typeof group === 'object' ? group : {};
|
|
23
|
+
return {
|
|
24
|
+
n: Number.isFinite(entry.confidence?.n) ? entry.confidence.n : (Number.isFinite(entry.deployments_total) ? entry.deployments_total : 0),
|
|
25
|
+
score: Number.isFinite(entry.score?.weighted_success) ? entry.score.weighted_success : 0,
|
|
26
|
+
confidence: typeof entry.confidence?.level === 'string' ? entry.confidence.level : '',
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 显著变化判定(初值,待实测后校准):样本数增加 ≥2,或加权成功分变化 ≥0.1,
|
|
31
|
+
// 或置信度升级。
|
|
32
|
+
const FINGERPRINT_CONFIDENCE_RANK = { low: 0, medium: 1, high: 2 };
|
|
33
|
+
export function dataSignificantlyChanged(prev, next) {
|
|
34
|
+
if (next.n - prev.n >= 2) return true;
|
|
35
|
+
if (Math.abs(next.score - prev.score) >= 0.1) return true;
|
|
36
|
+
return (FINGERPRINT_CONFIDENCE_RANK[next.confidence] ?? -1) > (FINGERPRINT_CONFIDENCE_RANK[prev.confidence] ?? -1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// suggestion-expire 事件:候选到期且数据显著变化、换代时转 expired 的留痕(仅记录)。
|
|
40
|
+
export function expireAuditEntry(asset, reason) {
|
|
41
|
+
return {
|
|
42
|
+
event: 'suggestion-expire',
|
|
43
|
+
profile_id: typeof asset.id === 'string' ? asset.id : '',
|
|
44
|
+
from: asset.from,
|
|
45
|
+
to: asset.to,
|
|
46
|
+
axis: asset.axis,
|
|
47
|
+
direction: asset.direction,
|
|
48
|
+
human_confirmed: false,
|
|
49
|
+
reason,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// suggestion-refresh 事件:人工触发「重新生成候选」的留痕(人工触发即确认)。
|
|
54
|
+
export function refreshAuditEntry({ profileKey, from, to, axis, direction }) {
|
|
55
|
+
return {
|
|
56
|
+
event: 'suggestion-refresh',
|
|
57
|
+
profile_id: profileKey,
|
|
58
|
+
from,
|
|
59
|
+
to,
|
|
60
|
+
axis,
|
|
61
|
+
direction,
|
|
62
|
+
human_confirmed: true,
|
|
63
|
+
};
|
|
64
|
+
}
|