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.
- package/README.md +49 -20
- package/README.zh.md +102 -73
- package/docs/screenshots/dispatch-card.png +0 -0
- package/docs/screenshots/settings-page1.png +0 -0
- package/docs/screenshots/settings-page2.png +0 -0
- package/index.mjs +153 -75
- package/lib/client.js +3081 -444
- package/lib/core/adoption-reminder.mjs +48 -0
- package/lib/core/adoption-tracker.mjs +430 -0
- package/lib/core/background-ledger.mjs +71 -0
- package/lib/core/cost-evidence.mjs +145 -0
- package/lib/core/cost-guard.mjs +1 -1
- package/lib/core/decision-trace.mjs +11 -31
- package/lib/core/delegation.mjs +11 -7
- package/lib/core/dispatch-gates.mjs +12 -5
- package/lib/core/dispatch-guard.mjs +6 -0
- package/lib/core/dispatch-schema.mjs +10 -5
- package/lib/core/dispatch-tool.mjs +52 -45
- package/lib/core/draft-gates.mjs +45 -0
- package/lib/core/drafts-store.mjs +45 -0
- package/lib/core/evolution-advice.mjs +323 -0
- package/lib/core/evolution-assets.mjs +167 -0
- package/lib/core/evolution-draft.mjs +312 -0
- package/lib/core/evolution-engine.mjs +269 -0
- package/lib/core/evolution-generate.mjs +196 -0
- package/lib/core/evolution-ledger.mjs +149 -33
- package/lib/core/evolution-persistence.mjs +213 -0
- package/lib/core/evolution-renewal.mjs +64 -0
- package/lib/core/evolution-routes.mjs +109 -0
- package/lib/core/evolution-summary.mjs +46 -223
- package/lib/core/http-helpers.mjs +35 -0
- package/lib/core/http-routes.mjs +169 -115
- package/lib/core/presets-sync.mjs +254 -256
- package/lib/core/prices.mjs +46 -0
- package/lib/core/profile-directory.mjs +139 -0
- package/lib/core/profile-provider.mjs +5 -3
- package/lib/core/profiles-store.mjs +24 -6
- package/lib/core/reminder-store.mjs +172 -0
- package/lib/core/session-read.mjs +17 -0
- package/lib/core/shims.mjs +13 -2
- package/package.json +82 -82
- 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
|
-
//
|
|
165
|
-
|
|
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 {
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
247
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|