dsh-session-rollover 0.0.0-stage → 1.2.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 (69) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/LICENSE +201 -0
  3. package/LICENSE.upstream +21 -0
  4. package/NOTICE +9 -0
  5. package/README.md +157 -2
  6. package/assets/icon.png +0 -0
  7. package/assets/icon.svg +7 -0
  8. package/client.js +656 -0
  9. package/compatibility/policy.json +21 -0
  10. package/compatibility/runtime-0.2.2-rc.1.json +32 -0
  11. package/cordis.patch.yml +9 -0
  12. package/docs/COMPATIBILITY.md +70 -0
  13. package/docs/DETAIL.md +44 -0
  14. package/docs/DETAIL.zh-CN.md +49 -0
  15. package/docs/DEVELOPMENT.md +104 -0
  16. package/docs/PUBLISHING.md +80 -0
  17. package/docs/architecture.md +71 -0
  18. package/docs/live-host-validation.json +37 -0
  19. package/docs/marketplace-validation.md +22 -0
  20. package/docs/prototype-validation.json +10 -0
  21. package/docs/r3-demo.html +981 -0
  22. package/docs/r3-validation.md +59 -0
  23. package/docs/screenshots/help-dark.png +0 -0
  24. package/docs/screenshots/help-light.png +0 -0
  25. package/docs/screenshots/host-archived.png +0 -0
  26. package/docs/screenshots/host-dark.png +0 -0
  27. package/docs/screenshots/host-rollover.png +0 -0
  28. package/docs/screenshots/prototype-fixed.png +0 -0
  29. package/index.js +2 -0
  30. package/locale/en.json +7 -0
  31. package/locale/zh.json +7 -0
  32. package/marketplace/Oak-liuhj__dsh-session-rollover.yml +7 -0
  33. package/marketplace/SUBMISSION.md +37 -0
  34. package/package.json +103 -4
  35. package/screenshots.json +6 -0
  36. package/scripts/build-client.mjs +12 -0
  37. package/scripts/check-dsh-compatibility.mjs +44 -0
  38. package/scripts/check-package.mjs +51 -0
  39. package/scripts/check-release.mjs +76 -0
  40. package/scripts/dsh-contracts.mjs +69 -0
  41. package/src/application/handoff-message.mjs +53 -0
  42. package/src/application/rollover.mjs +236 -0
  43. package/src/assembly.mjs +4 -0
  44. package/src/client/rollover-ui.js +311 -0
  45. package/src/client/toast-client.js +342 -0
  46. package/src/cluster.mjs +3 -0
  47. package/src/domain/cluster-index.mjs +65 -0
  48. package/src/domain/handoff.mjs +67 -0
  49. package/src/domain/inflight.mjs +25 -0
  50. package/src/domain/mode.mjs +34 -0
  51. package/src/domain/naming.mjs +78 -0
  52. package/src/domain/summary.mjs +68 -0
  53. package/src/domain/todos.mjs +23 -0
  54. package/src/domain/turns.mjs +43 -0
  55. package/src/handoff.mjs +2 -0
  56. package/src/inflight.mjs +2 -0
  57. package/src/mode.mjs +2 -0
  58. package/src/naming.mjs +2 -0
  59. package/src/notices.mjs +2 -0
  60. package/src/server/compatibility.mjs +84 -0
  61. package/src/server/host-adapter.mjs +146 -0
  62. package/src/server/notice-route.mjs +104 -0
  63. package/src/server/plugin.mjs +447 -0
  64. package/src/storage/index-store.mjs +48 -0
  65. package/src/storage/notices.mjs +117 -0
  66. package/src/storage/rollover-state.mjs +75 -0
  67. package/src/summary.mjs +2 -0
  68. package/src/todos.mjs +2 -0
  69. package/src/turns.mjs +2 -0
@@ -0,0 +1,447 @@
1
+ // 服务端插件:注册服务、拦截只读旧卷、调度自动/手动分卷与告警。
2
+ import {appendFileSync, mkdirSync, readFileSync} from 'node:fs';
3
+ import {join} from 'node:path';
4
+ import {countTurns, hasOpenTurn, DEFAULT_THRESHOLD} from '../domain/turns.mjs';
5
+ import {createClusterIndex} from '../domain/cluster-index.mjs';
6
+ import {attachFilePersistence, resolveDataDir} from '../storage/index-store.mjs';
7
+ import {createRolloverState} from '../storage/rollover-state.mjs';
8
+ import {performRollover} from '../application/rollover.mjs';
9
+ import {textOf} from '../domain/summary.mjs';
10
+ import {transitionInFlight} from '../domain/mode.mjs';
11
+ import {createNoticeStore} from '../storage/notices.mjs';
12
+ import {createHostAdapter} from './host-adapter.mjs';
13
+ import {attachNoticeRoutes, handlePendingNotices} from './notice-route.mjs';
14
+ import {inspectHost, preflightHost, INCOMPATIBLE_MESSAGE} from './compatibility.mjs';
15
+
16
+ const inflightKey = (id) => `rollover:${id}`;
17
+
18
+ /** B6/B5 用户可见告警文案(需求 v1.2 表15:旧卷保留原名与内容+告警一次;B5 超限只提醒)
19
+ * 文案经 UI 定稿:一行系统消息、无 emoji、无内部术语(T6/T7/rollback 不外露)。 */
20
+ const NOTICE_TEXT = {
21
+ failed: '分卷未完成:已保留原会话名与内容,下次发消息时自动重试。',
22
+ 'rename-old-failed': '分卷未完成:已保留原会话名与内容,下次发消息时自动重试。',
23
+ // 旧卷改名后回滚本身失败:此时「已保留原名」不成立,必须换一句,让用户去侧栏核对(文案第②点)
24
+ 'rollback-failed': '分卷未完成:新卷未建立,原会话名称可能未复原,请在侧栏核对。',
25
+ 'manual-pending': '已到分卷阈值:manual 模式下自动分卷已暂停,可手动发起分卷。',
26
+ 'retry-limit': '分卷摘要连续失败,已停止自动重试;原会话名与内容保持不变。',
27
+ 'summary-error': '分卷摘要连续失败,已停止自动重试;原会话名与内容保持不变。',
28
+ 'cluster-cap': '同簇已达 9 卷上限,请新建独立会话继续。',
29
+ 'storage-unavailable': '分卷数据目录不可写,已保留原会话,请检查目录后重试。',
30
+ 'user-renamed': '已保留手动设置的会话名称。',
31
+ 'host-incompatible': INCOMPATIBLE_MESSAGE,
32
+ };
33
+
34
+ /**
35
+ * @typedef {object} RolloverConfig
36
+ * @property {'auto'|'manual'|'off'} [mode='auto'] 自动判定模式;不限制显式手动调用
37
+ * @property {number} [threshold=10] 自动分卷的轮次阈值
38
+ * @property {boolean} [forceAuto=false] 检测到外部分段标记时仍保留 auto
39
+ * @property {string} [dataDir] 插件数据目录;未指定时使用环境变量或 DSH 默认目录
40
+ */
41
+
42
+ /**
43
+ * 宿主插件生命周期入口。业务规则由 domain 层维护,分卷副作用由 application 层编排。
44
+ * 自动与手动入口共用会话锁;宿主服务转换与提示路由分别委托给独立模块。
45
+ */
46
+ export default class SessionRolloverPlugin {
47
+ static inject = ['sessions', 'sessionTitle', 'sessionController', 'sessionProjections', 'workspaceRegistry', 'connection'];
48
+
49
+ /**
50
+ * @param {object} ctx 宿主服务容器
51
+ * @param {RolloverConfig} [config]
52
+ */
53
+ constructor(ctx, config = {}) {
54
+ this.ctx = ctx;
55
+ this.services = typeof ctx.inject === 'function' ? {} : ctx;
56
+ if (ctx.inject) {
57
+ for (const name of ['jobs', 'agentDefaultModel', 'workingDirectory', 'agents']) ctx.inject([name], scope => {
58
+ const service = scope[name];
59
+ this.services[name] = service;
60
+ scope.effect?.(() => () => { if (this.services[name] === service) delete this.services[name]; });
61
+ });
62
+ }
63
+ this.config = {mode: 'auto', threshold: DEFAULT_THRESHOLD, ...config};
64
+ this.dataDir = resolveDataDir({configDir: this.config.dataDir, env: process.env});
65
+ this.index = createClusterIndex();
66
+ this.persistence = attachFilePersistence(this.index, this.dataDir);
67
+ this.state = createRolloverState(this.dataDir);
68
+ // B6:去重键+「未发显」队列落盘(与 readonly 开关同目录),server 侧不再 append 系统消息
69
+ this.notices = createNoticeStore(this.dataDir);
70
+ this.state.modelFallback = id => this.notices.queue(id, 'model-fallback', '新卷未能继承原模型,已回落默认模型。');
71
+ this.busy = new Set();
72
+ this.lastCheckedTurn = new Map();
73
+ this.loadIndex();
74
+ ctx.provide('sessionRollover', {
75
+ rollover: (id) => this.rolloverNow(id),
76
+ info: (id) => this.handoffInfo(id),
77
+ setReadonly: (id, value) => this.setReadonly(id, value),
78
+ compatibility: () => inspectHost(this.ctx),
79
+ });
80
+ ctx.effect(() => () => {
81
+ this.busy.clear();
82
+ }); // 卸载时放弃在跑的判定,不残留定时/监听
83
+ ctx.on('agent/pre-step', (payload, next) => this.onPreStep(payload, next), {prepend: true});
84
+ // 用户轮次计数与执行切点分离:等真实回合/压缩结束后检查,不在模型执行中切卷。
85
+ ctx.on('session/event', (session, event) => {
86
+ if (['turn/end', 'compaction/end', 'part/end', 'tool/result'].includes(event.type ?? event.kind)) {
87
+ queueMicrotask(() => void this.maybeRollover({agent: {session}}).catch(e => this.log('error', {error: String(e.message)})));
88
+ }
89
+ });
90
+ this.attachRoutes(ctx);
91
+ this.log('boot', {
92
+ dataDir: this.dataDir, mode: this.config.mode, threshold: this.config.threshold,
93
+ degraded: this.persistence.degraded, notices: this.notices.path, noticesDegraded: this.notices.degraded,
94
+ compatibility: inspectHost(ctx),
95
+ });
96
+ }
97
+
98
+ /** 路由实现独立维护;保留方法入口便于宿主接线与诊断。 */
99
+ attachRoutes(ctx) {
100
+ attachNoticeRoutes(ctx, {
101
+ handlePending: (req, res) => this.handlePending(req, res),
102
+ log: (event, data) => this.log(event, data),
103
+ plugin: this,
104
+ });
105
+ }
106
+
107
+ handlePending(req, res) {
108
+ return handlePendingNotices({
109
+ notices: this.notices,
110
+ log: (event, data) => this.log(event, data),
111
+ }, req, res);
112
+ }
113
+
114
+ /** 索引=缓存:回灌后用已注入且未废弃的交接补齐缺项,不按任意 -N 标题猜测。 */
115
+ loadIndex() {
116
+ try {
117
+ const doc = JSON.parse(readFileSync(this.persistence.path, 'utf8'));
118
+ if (!doc.sessions || typeof doc.sessions !== 'object' || Array.isArray(doc.sessions) ||
119
+ Object.values(doc.sessions).some(r => !r?.cluster_id || !Number.isInteger(r.vol_no))) throw new Error('invalid-index');
120
+ for (const [sessionId, r] of Object.entries(doc.sessions ?? {})) {
121
+ this.index.record({
122
+ sessionId, clusterId: r.cluster_id, parentSessionId: r.parent_session_id,
123
+ volNo: r.vol_no, nameSuffix: r.name_suffix, rolloverState: r.rollover_state,
124
+ readonlyByRollover: r.readonly_by_rollover, summaryRetry: r.summary_retry,
125
+ });
126
+ }
127
+ if (doc.turn_counts) this.index.setTurnCounts(doc.turn_counts);
128
+ } catch { /* 缺失/坏文件继续从可信交接重建。 */ }
129
+ this.reconcileIndex();
130
+ }
131
+
132
+ /** 启动修复缓存缺项;失败注入产生的待清理卷不参与有效父链。 */
133
+ reconcileIndex() {
134
+ if (!inspectHost(this.ctx).compatible) return;
135
+ let sessions;
136
+ try { sessions = this.ctx.sessions.list(); } catch { return; }
137
+ if (!Array.isArray(sessions)) return;
138
+ let changed = false;
139
+ const abandoned = new Set(this.state.pendingList().map(r => r.sessionId));
140
+ const snapshots = new Map();
141
+ for (const session of sessions) {
142
+ if (!session || typeof session.id !== 'string') continue;
143
+ if (abandoned.has(session.id)) continue;
144
+ let h;
145
+ let events;
146
+ try { events = session.snapshotEvents(); } catch { continue; }
147
+ if (!Array.isArray(events)) continue;
148
+ for (const e of events) {
149
+ const text = textOf(e);
150
+ if (!text.startsWith('【分卷交接】')) continue;
151
+ try { h = JSON.parse(/```json\s*([\s\S]*?)```/.exec(text)?.[1]); } catch { /* 非本插件快照 */ }
152
+ if (h?.rollover === 'v1') { try { this.state.handoffSave(session.id, h); } catch {} break; }
153
+ }
154
+ if (h?.rollover === 'v1' && h?.cluster?.cluster_id) snapshots.set(session.id, h);
155
+ }
156
+ const depth = (id, seen = new Set()) => {
157
+ if (seen.has(id)) return 1;
158
+ seen.add(id);
159
+ const h = snapshots.get(id);
160
+ return h ? h.cluster.vol_no || (h.cluster.independent ? 1 : depth(h.from_session_id, seen) + 1) : 1;
161
+ };
162
+ for (const [sessionId, h] of snapshots) {
163
+ const volume = depth(sessionId);
164
+ const parent = h.cluster.independent ? null : h.from_session_id;
165
+ const parentRec = parent && this.index.get(parent);
166
+ if (parent && (!parentRec || parentRec.rollover_state !== 'sealed')) {
167
+ changed = true;
168
+ this.index.record({sessionId: parent, clusterId: h.cluster.cluster_id,
169
+ parentSessionId: parentRec?.parent_session_id, volNo: volume - 1,
170
+ nameSuffix: parentRec?.name_suffix ?? '', rolloverState: 'sealed',
171
+ readonlyByRollover: this.state.readonlyRecord(parent)?.readonly ?? true,
172
+ summaryRetry: parentRec?.summary_retry ?? 0});
173
+ }
174
+ const own = this.index.get(sessionId);
175
+ if (!own) {
176
+ changed = true;
177
+ this.index.record({sessionId, clusterId: h.cluster.cluster_id,
178
+ parentSessionId: parent, volNo: volume, nameSuffix: h.to_session_title?.match(/-\d+$/)?.[0] ?? '',
179
+ rolloverState: 'active', readonlyByRollover: false});
180
+ }
181
+ }
182
+ if (changed) this.persistence.persist();
183
+ }
184
+
185
+ log(event, data = {}) {
186
+ try {
187
+ this.ctx.logger?.('dsh-session-rollover')?.info?.(event, data);
188
+ } catch { /* 无 logger 也不影响主流程 */
189
+ }
190
+ try {
191
+ this.ctx.get?.('telemetry')?.event?.('rollover', {event, ...data});
192
+ } catch { /* 可选面 */
193
+ }
194
+ // 证据面:同一条日志落插件数据目录(不进 work_dir、不进发布 zip),便于实机取证与排障
195
+ try {
196
+ mkdirSync(this.dataDir, {recursive: true});
197
+ appendFileSync(join(this.dataDir, 'plugin.log.jsonl'), `${JSON.stringify({
198
+ at: new Date().toISOString(),
199
+ event, ...data
200
+ })}\n`);
201
+ } catch { /* 落盘失败不阻断 */
202
+ }
203
+ }
204
+
205
+ sessionOf(id) {
206
+ try {
207
+ return this.ctx.sessions.get(id) ?? null;
208
+ } catch {
209
+ return null;
210
+ }
211
+ }
212
+
213
+ /** 查询来源卷、完整交接快照和只读状态;供面板读取,不触发分卷。 */
214
+ handoffInfo(id) {
215
+ const record = this.index.get(id);
216
+ const handoff = this.state.handoffGet(id);
217
+ const parent = handoff?.cluster?.independent ? null : handoff?.from_session_id ?? record?.parent_session_id;
218
+ let title = handoff?.from_title || parent;
219
+ const source = parent && this.sessionOf(parent);
220
+ if (source) { try { title = this.ctx.sessionTitle.get(source)?.title || title; } catch {} }
221
+ const session = this.sessionOf(id);
222
+ let events = [];
223
+ try { const snapshot = session?.snapshotEvents?.(); if (Array.isArray(snapshot)) events = snapshot; } catch {}
224
+ let sessionTitle = '';
225
+ try { sessionTitle = session ? this.ctx.sessionTitle.get(session)?.title ?? '' : ''; } catch {}
226
+ const sealed = record?.rollover_state === 'sealed';
227
+ const turnCount = countTurns(events);
228
+ const children = record ? this.index.cluster(record.cluster_id).filter(r => r.parent_session_id === id) : [];
229
+ const retry = this.state.retryGet(id).attempts;
230
+ return {source: parent ? {sessionId: parent, title} : null, handoff,
231
+ readonly: this.isReadonly(id), sealed, sessionId: id,
232
+ title: sessionTitle,
233
+ compatibility: session ? preflightHost(this.ctx, session, this.services) : inspectHost(this.ctx),
234
+ turnCount, threshold: this.config.threshold, volume: record?.vol_no ?? null, cap: 9,
235
+ capped: record?.vol_no >= 9, summaryRetry: retry,
236
+ retryLimitReached: retry >= 3, degraded: this.persistence.degraded || !!this.stateDegraded?.has(id),
237
+ previousTurnCount: source ? (() => { try { const e = source.snapshotEvents(); return Array.isArray(e) ? countTurns(e) : null; } catch { return null; } })() : null,
238
+ nextSessionId: children[0]?.sessionId ?? null};
239
+ }
240
+
241
+ isReadonly(id) {
242
+ const per = this.state.readonlyRecord(id);
243
+ return per ? per.readonly !== false : this.state.isReadonly(id) || !!this.index.get(id)?.readonly_by_rollover;
244
+ }
245
+
246
+ setReadonly(id, readonly) {
247
+ if (typeof readonly !== 'boolean') throw new Error('readonly must be boolean');
248
+ if (!this.sessionOf(id)) throw new Error('session-unavailable');
249
+ const rec = this.index.get(id);
250
+ if (rec?.rollover_state !== 'sealed') throw new Error('session-not-archived');
251
+ this.state.setReadonly(id, readonly);
252
+ rec.readonly_by_rollover = readonly;
253
+ this.persistence.persist();
254
+ return this.handoffInfo(id);
255
+ }
256
+
257
+ /** 显式手动分卷:绕过轮次/模式门槛,仍执行只读、子会话与工具安全检查。 */
258
+ async rolloverNow(id) {
259
+ const session = this.sessionOf(id);
260
+ if (!session) return {status: 'failed', reason: 'session-unavailable'};
261
+ const compatibility = preflightHost(this.ctx, session, this.services);
262
+ if (!compatibility.compatible) return {status: 'failed', reason: 'host-incompatible', compatibility};
263
+ if (this.busy.has(inflightKey(id))) return {status: 'skipped', reason: 'busy'};
264
+ if (this.index.get(id)?.rollover_state === 'sealed') return {status: 'skipped', reason: 'readonly'};
265
+ this.busy.add(inflightKey(id));
266
+ try {
267
+ try { this.state.retrySave(id, 0); } catch (e) {
268
+ return {status: 'failed', reason: 'storage-unavailable', error: String(e.message)};
269
+ } // 显式人工操作开始一次新尝试,不恢复无限自动重试。
270
+ const host = this.hostFor(session);
271
+ const result = await performRollover({host, sessionId: id, index: this.index,
272
+ persistence: this.persistence, state: this.state, config: this.config, manual: true,
273
+ });
274
+ this.afterResult(session, result);
275
+ return result;
276
+ } finally { this.busy.delete(inflightKey(id)); }
277
+ }
278
+
279
+ /** 每次事务绑定来源会话,避免工作区、模型选择和事件读取串到其他会话。 */
280
+ hostFor(session) {
281
+ return createHostAdapter({
282
+ ctx: this.ctx, session, state: this.state,
283
+ services: this.services,
284
+ sessionOf: (id) => this.sessionOf(id),
285
+ log: (event, data) => this.log(event, data),
286
+ });
287
+ }
288
+
289
+ /** 首步拦截已封旧卷;正常回合放行后异步检查分卷,不等待事务完成。 */
290
+ async onPreStep(payload, next) {
291
+ // 只在「新一轮第一步」判定/拦截:分卷触发的那个回合在 T8 才把旧卷标只读,
292
+ // 若后续 step 也拦,触发轮会被自己封死(工具调用链直接 blocked)。
293
+ const firstStep = payload?.step == null || payload.step === 1;
294
+ // AC-R3-7 服务端闸:-1 旧卷只读,连触发都不许(宿主约定 pre-step 返回 {kind:'reject'} → 本回合 blocked)
295
+ const sid = payload?.agent?.session?.id;
296
+ try {
297
+ if (firstStep && sid && this.isReadonly(sid)) {
298
+ this.readonlyBlocked ??= new Set();
299
+ if (!this.readonlyBlocked.has(sid)) {
300
+ this.readonlyBlocked.add(sid);
301
+ this.log('readonly-blocked', {sessionId: sid});
302
+ }
303
+ return {kind: 'reject'};
304
+ }
305
+ } catch { /* 只读判定失败不阻断正常回合 */
306
+ }
307
+ const out = await next();
308
+ if (firstStep) {
309
+ try {
310
+ void this.maybeRollover(payload).catch((e) => this.log('error', {error: String(e?.message ?? e)}));
311
+ } catch { /* 旁挂判定绝不拖垮回合 */
312
+ }
313
+ }
314
+ return out;
315
+ }
316
+
317
+ /** 自动入口:会话内每个轮次最多检查一次,锁在事务结束后释放。 */
318
+ async maybeRollover({agent} = {}) {
319
+ const session = agent?.session;
320
+ if (!session?.id) return;
321
+ const id = session.id;
322
+ const compatibility = preflightHost(this.ctx, session, this.services);
323
+ if (!compatibility.compatible) {
324
+ this.notifyOnce(session, {reason: 'host-incompatible'});
325
+ return;
326
+ }
327
+ if (this.busy.has(inflightKey(id))) return;
328
+ // 已封卷的 -N 永不再分卷:B9 的手动开关只放行「在旧卷继续写」,不等于再开新卷。
329
+ try {
330
+ const rec = this.index?.get?.(id);
331
+ if (rec?.rollover_state === 'sealed') {
332
+ this.sealedSkipped ??= new Set();
333
+ if (!this.sealedSkipped.has(id)) {
334
+ this.sealedSkipped.add(id);
335
+ this.log('skip-sealed', {sessionId: id});
336
+ }
337
+ return;
338
+ }
339
+ } catch { /* 索引读不到不阻断判定 */
340
+ }
341
+ const events = session.snapshotEvents();
342
+ if (hasOpenTurn(events) || transitionInFlight(events)) return;
343
+ const turns = countTurns(events);
344
+ this.seen ??= new Set();
345
+ if (!this.seen.has(id)) {
346
+ this.seen.add(id);
347
+ this.log('pre-step-seen', {sessionId: id, turns, events: events.length});
348
+ }
349
+ if (turns < this.config.threshold) return;
350
+ if (this.state.retryGet(id).attempts >= 3) {
351
+ this.notifyOnce(session, {reason: 'retry-limit'});
352
+ return;
353
+ }
354
+ if (this.lastCheckedTurn.get(id) === turns) return; // 同一轮次不重复试探
355
+ this.lastCheckedTurn.set(id, turns);
356
+ this.busy.add(inflightKey(id));
357
+ try {
358
+ // 待办取日志最后一次 todo/write:此刻 turn/start 已把实时投影清零(见 domain/todos.mjs)。
359
+ const res = await performRollover({
360
+ host: this.hostFor(session), sessionId: id, index: this.index,
361
+ persistence: this.persistence, state: this.state, config: this.config,
362
+ });
363
+ if (res.reason === 'unsafe-cut-point' || res.reason === 'transition-in-flight') this.lastCheckedTurn.delete(id);
364
+ this.afterResult(session, res);
365
+ if (res.status === 'rolled-over') {
366
+ this.log('rolled-over', {
367
+ from: id,
368
+ to: res.newSessionId,
369
+ cluster: res.clusterId,
370
+ bytes: res.handoffBytes
371
+ });
372
+ // B6:成功一次即清该会话去重键(否则键只增不减,后续真失败会被历史键吃掉)
373
+ } else {
374
+ this.log('not-rolled-over', {
375
+ sessionId: id,
376
+ turns,
377
+ status: res.status,
378
+ reason: res.reason ?? null,
379
+ error: res.error ?? null,
380
+ bytes: res.handoffBytes ?? null
381
+ });
382
+ // B6 / B5:失败与「只提醒不动作」必须用户看得见一次(需求 v1.2 表15 逐字要求;纯日志不算)
383
+ if (res.status === 'failed' || res.notice === true) this.notifyOnce(session, res);
384
+ }
385
+ } finally {
386
+ this.busy.delete(inflightKey(id));
387
+ }
388
+ }
389
+
390
+ afterResult(session, result) {
391
+ try {
392
+ if (result.status === 'rolled-over') {
393
+ this.lastCompleted ??= new Map();
394
+ this.lastCompleted.set(session.id, result.newSessionId);
395
+ try { this.ctx.emit?.('rollover/completed', {fromSessionId: session.id, sessionId: result.newSessionId}); } catch {}
396
+ this.state.retrySave(session.id, 0);
397
+ this.notices.clearSession(session.id);
398
+ this.notices.queue(session.id, 'completed', '分卷完成,已创建接续会话。');
399
+ } else if (['oversize', 'summary-missing', 'summary-error', 'summary-too-long'].includes(result.reason)) {
400
+ const n = this.state.retryGet(session.id).attempts + 1;
401
+ this.state.retrySave(session.id, n);
402
+ const rec = this.index.get(session.id);
403
+ if (rec) { rec.summary_retry = n; this.persistence.persist(); }
404
+ if (n >= 3) result.reason = 'retry-limit';
405
+ }
406
+ if (result.degraded) {
407
+ this.stateDegraded ??= new Set();
408
+ for (const id of [session.id, result.newSessionId].filter(Boolean)) this.stateDegraded.add(id);
409
+ }
410
+ } catch (e) {
411
+ // 会话已经建成时,通知或重试状态落盘失败不能把成功结果翻成失败后再次建卷。
412
+ result.degraded = true;
413
+ this.stateDegraded ??= new Set();
414
+ for (const id of [session.id, result.newSessionId].filter(Boolean)) this.stateDegraded.add(id);
415
+ this.log('state-degraded', {sessionId: session.id, error: String(e.message)});
416
+ }
417
+ if (result.status === 'failed' || result.notice) this.notifyOnce(session, result);
418
+ }
419
+
420
+ /**
421
+ * 告警一次(B6):入队到落盘镜像,等 client 半取走渲染。
422
+ * v4 实测:server 侧 session.append('system/message') 在 Bot 会话上不渲染、不落盘,
423
+ * 宿主「记忆已更新」同族提示也是 client 即时提示 → B6 通道改为「server 判定 + client 渲染」。
424
+ * 去重键=(会话, 原因) 落盘:同因连败只提示一次,换因再提示;分卷成功即清该会话键。
425
+ */
426
+ notifyOnce(session, res) {
427
+ if (!session?.id) return;
428
+ const reason = res?.rollbackFailed ? 'rollback-failed' : (res?.reason ?? 'failed');
429
+ const text = NOTICE_TEXT[reason] ?? NOTICE_TEXT.failed;
430
+ try {
431
+ const out = this.notices.queue(session.id, reason, text);
432
+ if (out.duplicate) this.log('notice-dedupe', {sessionId: session.id, reason});
433
+ else if (out.queued) this.log('notice-queued', {
434
+ sessionId: session.id,
435
+ reason,
436
+ text,
437
+ id: out.id,
438
+ degraded: out.degraded ?? null
439
+ });
440
+ else this.log('notice-skipped', {sessionId: session.id, reason, why: out.reason ?? null});
441
+ } catch (e) {
442
+ this.log('notice-failed', {sessionId: session.id, error: String(e?.message ?? e)});
443
+ }
444
+ }
445
+ }
446
+
447
+ export {SessionRolloverPlugin};
@@ -0,0 +1,48 @@
1
+ // 索引文件存储与插件数据目录解析;升级时沿用既有文件格式。
2
+ import {mkdirSync, readFileSync, renameSync, writeFileSync} from 'node:fs';
3
+ import {homedir} from 'node:os';
4
+ import {join} from 'node:path';
5
+ import {createClusterIndex} from '../domain/cluster-index.mjs';
6
+
7
+ /** dshHome 解析:语义照 @deepseek-ai/dsh-home-paths README(配置 > $DSH_HOME > ~/.dsh;空白视为未设置)。零依赖等价实现。 */
8
+ export function resolveDataDir({configDir, env = process.env, home = homedir()} = {}) {
9
+ if (configDir) return configDir;
10
+ const dd = env.DSH_ROLLOVER_DATA_DIR;
11
+ if (dd && dd.trim()) return dd.trim();
12
+ const h = env.DSH_HOME;
13
+ const root = (h && h.trim()) ? h.trim() : join(home, '.dsh');
14
+ return join(root, 'plugin-data', 'dsh-session-rollover');
15
+ }
16
+
17
+ /** 落盘:临时文件 + rename 原子替换;目录不可写 → 显式降级纯内存(接口契约),不抛 */
18
+ export function attachFilePersistence(index, dir, file = 'cluster-index.json') {
19
+ const path = join(dir, file);
20
+ return {
21
+ path,
22
+ degraded: false,
23
+ lastError: null,
24
+ load() {
25
+ try {
26
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
27
+ return createClusterIndex(raw);
28
+ } catch {
29
+ return index;
30
+ } // 无文件/坏文件 → 空索引起步,交给 rebuild 对账
31
+ },
32
+ persist() {
33
+ try {
34
+ mkdirSync(dir, {recursive: true});
35
+ const tmp = path + '.tmp';
36
+ writeFileSync(tmp, index.serialize());
37
+ renameSync(tmp, path);
38
+ this.degraded = false;
39
+ this.lastError = null;
40
+ return path;
41
+ } catch (e) {
42
+ this.degraded = true;
43
+ this.lastError = String(e && e.message || e);
44
+ return null;
45
+ }
46
+ },
47
+ };
48
+ }
@@ -0,0 +1,117 @@
1
+ // B6 一次性提示的镜像存储:去重键(会话,原因)+「未发显」待投递队列。
2
+ // 与 B9 readonly 开关同目录(<DSH_HOME>/plugin-data/dsh-session-rollover);
3
+ // 原子写(tmp + rename),坏文件不抛、降级成空存储(下一次写覆盖)。
4
+ // 设计:服务端只负责「该不该提示、提示什么」;渲染是 client 半的事(宿主 server 侧无 toast)。
5
+ import {mkdirSync, readFileSync, renameSync, writeFileSync} from 'node:fs';
6
+ import {join} from 'node:path';
7
+
8
+ export const NOTICE_FILE = 'notices.json';
9
+ const MAX_PENDING_PER_SESSION = 20;
10
+ const MAX_DEDUPE = 500;
11
+
12
+ const emptyDoc = () => ({version: 1, dedupe: {}, pending: {}});
13
+
14
+ /**
15
+ * @param {string} dir 插件数据目录(与 cluster-index.json / readonly-*.json 同处)
16
+ * @param {{ file?: string, now?: () => number }} [opts]
17
+ */
18
+ export function createNoticeStore(dir, opts = {}) {
19
+ const path = opts.file ?? join(dir, NOTICE_FILE);
20
+ const now = opts.now ?? (() => Date.now());
21
+ const doc = emptyDoc();
22
+ let seq = 0;
23
+ let degraded = null;
24
+
25
+ function load() {
26
+ try {
27
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
28
+ if (parsed && typeof parsed === 'object') {
29
+ if (parsed.dedupe && typeof parsed.dedupe === 'object') Object.assign(doc.dedupe, parsed.dedupe);
30
+ if (parsed.pending && typeof parsed.pending === 'object') {
31
+ for (const [sid, list] of Object.entries(parsed.pending)) {
32
+ if (Array.isArray(list)) doc.pending[sid] = list.filter((n) => n && typeof n.text === 'string');
33
+ }
34
+ }
35
+ }
36
+ degraded = null;
37
+ } catch {
38
+ // 首启 / 坏文件:空存储;不覆盖原文件,等下一次 persist 自愈
39
+ degraded = 'unreadable';
40
+ }
41
+ return doc;
42
+ }
43
+
44
+ function persist() {
45
+ try {
46
+ mkdirSync(dir, {recursive: true});
47
+ const tmp = `${path}.tmp`;
48
+ writeFileSync(tmp, JSON.stringify(doc), 'utf8');
49
+ renameSync(tmp, path);
50
+ degraded = null;
51
+ return path;
52
+ } catch (e) {
53
+ degraded = String(e?.message ?? e);
54
+ return null;
55
+ }
56
+ }
57
+
58
+ /** 去重键存在 → 不再入队(同会话同因只提示一次)。成功分卷后由 clearSession 清键。 */
59
+ function queue(sessionId, reason, text) {
60
+ if (!sessionId) return {queued: false, duplicate: false, reason: 'no-session'};
61
+ const key = `${sessionId}|${reason}`;
62
+ if (doc.dedupe[key]) return {queued: false, duplicate: true, id: doc.dedupe[key].id ?? null};
63
+ const at = new Date(now()).toISOString();
64
+ const entry = {id: `${now()}-${(++seq).toString(36)}`, reason, text: String(text ?? ''), at};
65
+ doc.dedupe[key] = {id: entry.id, at};
66
+ const list = (doc.pending[sessionId] ??= []);
67
+ list.push(entry);
68
+ if (list.length > MAX_PENDING_PER_SESSION) list.splice(0, list.length - MAX_PENDING_PER_SESSION);
69
+ // 去重键防无限增长:超过上限按 at 丢最旧(丢了只是允许再提示一次,不会卡死)
70
+ const keys = Object.keys(doc.dedupe);
71
+ if (keys.length > MAX_DEDUPE) {
72
+ keys.sort((a, b) => String(doc.dedupe[a].at).localeCompare(String(doc.dedupe[b].at)));
73
+ for (const k of keys.slice(0, keys.length - MAX_DEDUPE)) delete doc.dedupe[k];
74
+ }
75
+ const written = persist();
76
+ return {queued: true, duplicate: false, id: entry.id, path: written, degraded};
77
+ }
78
+
79
+ /** client 取走即删(取走一次=投递一次;渲染失败由 client 静默,不影响分卷事务)。 */
80
+ function takePending(sessionId) {
81
+ if (!sessionId) return [];
82
+ const list = doc.pending[sessionId] ?? [];
83
+ if (!list.length) return [];
84
+ delete doc.pending[sessionId];
85
+ persist();
86
+ return list.slice();
87
+ }
88
+
89
+ /** 分卷成功一次 → 清该会话去重键与残留待投递(否则键只增不减,成功后真失败反而不提示)。 */
90
+ function clearSession(sessionId) {
91
+ if (!sessionId) return {dedupe: 0, pending: 0};
92
+ let dedupe = 0;
93
+ for (const key of Object.keys(doc.dedupe)) {
94
+ if (key.startsWith(`${sessionId}|`)) {
95
+ delete doc.dedupe[key];
96
+ dedupe += 1;
97
+ }
98
+ }
99
+ const pending = doc.pending[sessionId]?.length ?? 0;
100
+ delete doc.pending[sessionId];
101
+ if (dedupe || pending) persist();
102
+ return {dedupe, pending};
103
+ }
104
+
105
+ function snapshot() {
106
+ return JSON.parse(JSON.stringify(doc));
107
+ }
108
+
109
+ load();
110
+
111
+ return {
112
+ path, load, persist, queue, takePending, clearSession, snapshot,
113
+ get degraded() {
114
+ return degraded;
115
+ },
116
+ };
117
+ }