dsh-compact-relay 0.4.1 → 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.
@@ -0,0 +1,219 @@
1
+ /**
2
+ * 交接线保命判定 —— 纯函数,零依赖(0.4.2)。
3
+ *
4
+ * 背景(0.4.1 遗留的三个洞,见 docs/0.4.2-implementation-plan.md):
5
+ * 1. 交接提醒线仍是配置值(默认 60%),换到实际触发线更低的模型(如 55%)时,
6
+ * DSH 会先自己压历史,dcr 的交接提醒来不及;
7
+ * 2. 服务端保存校验只看配置阈值(80%),手改设置文件能设到 70%;
8
+ * 3. 旧值不跟随当前模型,除设置页一行 warning 外没有其它可见提示。
9
+ *
10
+ * 0.4.2 规则(范围定稿):
11
+ * 交接提醒线 ≤ 实际触发线 − 5pp(HANDOFF_MARGIN_PP),向下取整到整数百分位。
12
+ * - 实际触发线来自 state.native.ratio(按当前路由模型实时算,见 core/native-line.js);
13
+ * - 实际线算不出时:运行时不告警、不阻断;服务端保存退回配置阈值防呆(0.4.1 行为);
14
+ * - DSH 原生自动压缩关闭时不存在「被抢先压缩」的截止线,不判定;
15
+ * - 只告警 / 拒绝,绝不静默改写用户配置。
16
+ *
17
+ * 本模块只做判定与文案,不做 IO:服务端 `lib/actions/settings.js` 直接 import,
18
+ * 客户端由 `scripts/build-client.mjs` 的 `/* @inline core/handoff-guard.js *\/`
19
+ * 内联进 `lib/client.js`(同一份判定,避免 UI 与后台两套口径)。
20
+ */
21
+
22
+ /** 交接线相对实际触发线至少留 5 个百分点(1M 窗口 ≈ 50K tokens 缓冲)。 */
23
+ export const HANDOFF_MARGIN_PP = 5;
24
+
25
+ /** 实际线算不出、退回配置阈值防呆时的余量(0.4.1 口径:最高 = 配置阈值 −1pp)。 */
26
+ export const FALLBACK_MARGIN_PP = 1;
27
+
28
+ /** cap 的绝对上限(避免算出 96%+ 这种没有意义的上限)。 */
29
+ export const HANDOFF_LIMIT_DEFAULT = 0.95;
30
+
31
+ const EPS = 1e-9;
32
+
33
+ /**
34
+ * 合法窗口比例:必须是 0<r<1 的有限数。
35
+ * @param {unknown} value
36
+ * @returns {number|null}
37
+ */
38
+ function ratioOf(value) {
39
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 && value < 1 ? value : null;
40
+ }
41
+
42
+ /**
43
+ * 比例 → 百分数数字(一位小数,整数不带小数):0.678 → 67.8,0.62 → 62。
44
+ * @param {number|null} ratio
45
+ * @returns {number|null}
46
+ */
47
+ function percentOf(ratio) {
48
+ if (ratio === null) return null;
49
+ const percent = ratio * 100;
50
+ return Math.abs(percent - Math.round(percent)) < 0.05 ? Math.round(percent) : Number(percent.toFixed(1));
51
+ }
52
+
53
+ /**
54
+ * 比例 → 百分数文本(用于文案):0.678 → '67.8',0.62 → '62'。
55
+ * @param {number|null} ratio
56
+ * @returns {string}
57
+ */
58
+ function percentText(ratio) {
59
+ if (ratio === null) return '';
60
+ const percent = ratio * 100;
61
+ return Math.abs(percent - Math.round(percent)) < 0.05 ? String(Math.round(percent)) : percent.toFixed(1);
62
+ }
63
+
64
+ /**
65
+ * 只算交接线上限:参考线 − marginPp,向下取整到整数百分位,再夹到 limit。
66
+ *
67
+ * 浮点安全:`0.62 − 0.05` 在二进制里可能是 0.5700000000000001 / 0.5699999999999999,
68
+ * 直接 floor 会把 57 变成 56,所以加 EPS 再 floor。
69
+ *
70
+ * @param {number|null} referenceRatio 参考线(实际触发线或配置阈值),非法时返回 null
71
+ * @param {{marginPp?:number, limit?:number}} [options]
72
+ * @returns {number|null} 例如 0.62;参考线太小(减完 ≤0)时返回 null
73
+ */
74
+ export function handoffCapOf(referenceRatio, { marginPp = HANDOFF_MARGIN_PP, limit = HANDOFF_LIMIT_DEFAULT } = {}) {
75
+ const reference = ratioOf(referenceRatio);
76
+ if (reference === null) return null;
77
+ const margin = (Number.isFinite(marginPp) ? marginPp : HANDOFF_MARGIN_PP) / 100;
78
+ const max = Number.isFinite(limit) && limit > 0 && limit < 1 ? limit : HANDOFF_LIMIT_DEFAULT;
79
+ // 向下取整到整数百分位(例如 62.8 → 0.62),不是四舍五入。
80
+ const cap = Math.floor((reference - margin) * 100 + EPS) / 100;
81
+ if (!(cap > 0)) return null;
82
+ return Math.min(cap, max);
83
+ }
84
+
85
+ /** 「重算目标 ≤ 压缩线」时的补充提示(保持「交接线 > 压缩线」现有约束)。 */
86
+ function compactConflictText(capPercent, compactRatio) {
87
+ return `按实际线重算目标 ${capPercent}% 不高于压缩线 ${percentText(compactRatio)}%,请先把压缩线降到 ${capPercent}% 以下。`;
88
+ }
89
+
90
+ /**
91
+ * 判定「当前交接线是否会在 DSH 原生压缩之后」。
92
+ *
93
+ * @param {object} [input]
94
+ * @param {number|null} [input.actualRatio] 当前模型实际触发线(state.native.ratio);算不出/关闭时 null
95
+ * @param {number|null} [input.configuredRatio] 配置阈值(native.thresholdRatio),兜底用
96
+ * @param {number|null} [input.handoffRatio] 当前交接提醒线(thresholds.handoffRatio)
97
+ * @param {number|null} [input.compactRatio] 压缩提醒线(thresholds.compactRatio),判重算冲突
98
+ * @param {boolean} [input.autoEnabled] DSH 原生自动压缩是否开启(false → 不判定,交接线无截止线)
99
+ * @param {number} [input.limit] cap 绝对上限,默认 0.95
100
+ * @param {'none'|'configured'} [input.fallback]
101
+ * 'none'(运行时默认):实际线算不出就不判定(不告警、不阻断);
102
+ * 'configured'(服务端保存默认):实际线算不出时按配置阈值 −1pp 防呆(0.4.1 行为)。
103
+ * @returns {{
104
+ * source:'actual'|'configured'|null,
105
+ * actualRatio:number|null, actualPercent:number|null,
106
+ * configuredRatio:number|null, configuredPercent:number|null,
107
+ * handoffRatio:number|null, handoffPercent:number|null,
108
+ * compactRatio:number|null, compactPercent:number|null,
109
+ * cap:number|null, capPercent:number|null,
110
+ * ok:boolean, severity:'ok'|'warn'|'late'|'unknown',
111
+ * reason:'ok'|'late'|'margin'|'fallback'|'no-reference'|'invalid-handoff'|'no-cap',
112
+ * message:string, target:number|null, compactConflict:boolean
113
+ * }}
114
+ */
115
+ export function handoffGuard({
116
+ actualRatio = null,
117
+ configuredRatio = null,
118
+ handoffRatio = null,
119
+ compactRatio = null,
120
+ autoEnabled = true,
121
+ limit = HANDOFF_LIMIT_DEFAULT,
122
+ fallback = 'none',
123
+ } = {}) {
124
+ const actual = ratioOf(actualRatio);
125
+ const configured = ratioOf(configuredRatio);
126
+ const handoff = ratioOf(handoffRatio);
127
+ const compact = ratioOf(compactRatio);
128
+ const neutral = {
129
+ source: null,
130
+ actualRatio: actual,
131
+ actualPercent: percentOf(actual),
132
+ configuredRatio: configured,
133
+ configuredPercent: percentOf(configured),
134
+ handoffRatio: handoff,
135
+ handoffPercent: percentOf(handoff),
136
+ compactRatio: compact,
137
+ compactPercent: percentOf(compact),
138
+ cap: null,
139
+ capPercent: null,
140
+ ok: true,
141
+ severity: 'unknown',
142
+ reason: 'no-reference',
143
+ message: '',
144
+ target: null,
145
+ compactConflict: false,
146
+ };
147
+ // 原生自动压缩关着:DSH 不会自己压,不存在「提醒来不及」的截止线。
148
+ if (autoEnabled === false) return neutral;
149
+
150
+ /** 带 cap 的结果骨架(cap 已是整数百分位,×100 即整数)。 */
151
+ const withCap = (source, cap) => {
152
+ const capPercent = cap === null ? null : Math.round(cap * 100);
153
+ return {
154
+ ...neutral,
155
+ source,
156
+ cap,
157
+ capPercent,
158
+ target: source === 'actual' ? cap : null,
159
+ compactConflict: cap !== null && compact !== null && cap <= compact + EPS,
160
+ };
161
+ };
162
+
163
+ // 1) 实际触发线可用:唯一口径(−5pp),不再叠加「配置阈值 −1pp」。
164
+ if (actual !== null) {
165
+ const cap = handoffCapOf(actual, { limit });
166
+ if (cap === null) return { ...withCap('actual', null), reason: 'no-cap' };
167
+ const base = withCap('actual', cap);
168
+ if (handoff === null) return { ...base, reason: 'invalid-handoff' };
169
+ const conflict = base.compactConflict ? compactConflictText(base.capPercent, compact) : '';
170
+ // 等于实际触发线也算「晚」:DSH 会在同一位置自己压历史,提醒来不及。
171
+ if (handoff > actual - EPS) {
172
+ return {
173
+ ...base,
174
+ ok: false,
175
+ severity: 'late',
176
+ reason: 'late',
177
+ message:
178
+ `交接线已晚:当前模型实际触发 ${percentText(actual)}%,交接线 ${percentText(handoff)}% 会在原生压缩之后` +
179
+ `(DSH 会先压历史);最高可设 ${base.capPercent}%。${conflict}`,
180
+ };
181
+ }
182
+ if (handoff > cap + EPS) {
183
+ return {
184
+ ...base,
185
+ ok: false,
186
+ severity: 'warn',
187
+ reason: 'margin',
188
+ message:
189
+ `交接线偏晚:当前模型实际触发 ${percentText(actual)}%,交接线 ${percentText(handoff)}% ` +
190
+ `距原生压缩不足 ${HANDOFF_MARGIN_PP} 个百分点;最高可设 ${base.capPercent}%。${conflict}`,
191
+ };
192
+ }
193
+ return { ...base, ok: true, severity: 'ok', reason: 'ok' };
194
+ }
195
+
196
+ // 2) 实际线算不出 + 服务端保存:退回配置阈值防呆(0.4.1 行为,兼容旧客户端)。
197
+ if (fallback === 'configured' && configured !== null) {
198
+ const cap = handoffCapOf(configured, { marginPp: FALLBACK_MARGIN_PP, limit });
199
+ if (cap === null) return { ...withCap('configured', null), reason: 'no-cap' };
200
+ const base = withCap('configured', cap);
201
+ if (handoff === null) return { ...base, reason: 'invalid-handoff' };
202
+ if (handoff > cap + EPS) {
203
+ return {
204
+ ...base,
205
+ ok: false,
206
+ severity: 'warn',
207
+ reason: 'fallback',
208
+ message:
209
+ `「交接提醒线」必须低于 DSH 原生自动压缩线(配置阈值 ${percentText(configured)}%),` +
210
+ `否则原生压缩会先动手;最高只能设到 ${base.capPercent}%。` +
211
+ '想设更高请先在设置里关闭「DSH 原生自动压缩」。',
212
+ };
213
+ }
214
+ return { ...base, ok: true, severity: 'ok', reason: 'ok' };
215
+ }
216
+
217
+ // 3) 运行时且算不出:不告警、不阻断。
218
+ return neutral;
219
+ }
package/lib/core/relay.js CHANGED
@@ -8,6 +8,45 @@
8
8
  /** 会话日志里记录 relay 的类型(与压缩判断记录/pin 的 plugin record 同前缀)。 */
9
9
  export const RELAY_RECORD_TYPE = 'plugin:compact-relay/relay';
10
10
 
11
+ /** 历史标题前缀(0.5.0 命名反转:历史 N:<原名>)。 */
12
+ export const HISTORY_PREFIX = '历史';
13
+
14
+ /**
15
+ * 会话标题的 UTF-8 字节预算 —— 与 DSH `session-title` 的默认 `maxTitleBytes` 一致。
16
+ *
17
+ * 宿主 `sessionTitle.rename()` 会按这个预算把标题从尾部截断;前缀也算预算。
18
+ * 插件先自己按同一口径缩 base,保证「历史 N:」前缀不会被宿主的尾部截断吃掉。
19
+ */
20
+ export const MAX_TITLE_BYTES = 80;
21
+
22
+ /** 「历史 N:」标题的正则;空白/半角冒号的容忍度与下面的「交接第 N 次:」一致。 */
23
+ const HISTORY_TITLE_RE = new RegExp(`^${HISTORY_PREFIX}\\s*(\\d+)\\s*[::]\\s*([\\s\\S]*)$`);
24
+
25
+ /**
26
+ * 按 UTF-8 字节预算截断标题(不切坏多字节字符 / 代理对)。
27
+ *
28
+ * 与 DSH `session-title` 的 `truncateTitleUtf8` 同口径:取「能放进预算的最长前缀」,
29
+ * 不补省略号(补了会占预算,也会让前缀解析多一个尾巴)。
30
+ * maxBytes 非法/缺失时原样返回(纯函数层保持旧行为,预算只在调用方显式传入时生效)。
31
+ * @param {string} input
32
+ * @param {number} [maxBytes]
33
+ * @returns {string}
34
+ */
35
+ export function truncateTitleUtf8(input, maxBytes) {
36
+ if (typeof input !== 'string') return '';
37
+ if (!Number.isInteger(maxBytes) || maxBytes <= 0) return input;
38
+ if (Buffer.byteLength(input, 'utf8') <= maxBytes) return input;
39
+ let used = 0;
40
+ let output = '';
41
+ for (const character of input) {
42
+ const bytes = Buffer.byteLength(character, 'utf8');
43
+ if (used + bytes > maxBytes) break;
44
+ output += character;
45
+ used += bytes;
46
+ }
47
+ return output;
48
+ }
49
+
11
50
  /**
12
51
  * 标题统一格式:交接第 N 次:<原始标题>。
13
52
  * @param {number} index
@@ -19,20 +58,58 @@ export function relayTitleOf(index, baseTitle) {
19
58
  return `交接第 ${index} 次:${base}`;
20
59
  }
21
60
 
61
+ /**
62
+ * 历史标题统一格式:历史 N:<原始标题>。
63
+ *
64
+ * 空格位置/全角冒号/序号写法与 relayTitleOf 完全一致(序号不补零、不换算);
65
+ * 原名先 trim,空/非字符串回落「会话」。
66
+ *
67
+ * 0.5.0 可选第三参 `maxBytes`:给 UTF-8 字节预算时,**前缀先计入预算、超长只缩 base**,
68
+ * 保证宿主按 maxTitleBytes 截断时不会把「历史 N:」本身截掉。
69
+ * 不传时保持 0.4.3 的纯函数行为(不截断)。
70
+ * @param {number} index
71
+ * @param {string} [baseTitle]
72
+ * @param {number} [maxBytes]
73
+ * @returns {string}
74
+ */
75
+ export function historyTitleOf(index, baseTitle, maxBytes) {
76
+ const base = typeof baseTitle === 'string' && baseTitle.trim().length > 0 ? baseTitle.trim() : '会话';
77
+ if (!Number.isInteger(maxBytes) || maxBytes <= 0) return `${HISTORY_PREFIX} ${index}:${base}`;
78
+ const prefix = `${HISTORY_PREFIX} ${index}:`;
79
+ const budget = maxBytes - Buffer.byteLength(prefix, 'utf8');
80
+ // 预算连 base 都放不下时只保留前缀(宿主最多再截一次前缀,至少不会切坏字符)。
81
+ if (budget <= 0) return prefix;
82
+ const fitted = truncateTitleUtf8(base, budget).trimEnd() || truncateTitleUtf8('会话', budget);
83
+ return `${prefix}${fitted}`;
84
+ }
85
+
22
86
  /**
23
87
  * 从任意文本里剥掉现代/历史交接前缀,拿到原始标题。
24
88
  * 旧格式(「交接:」/「接棒:」可堆叠)只在读旧标题时兼容,不删除会话标题。
89
+ *
90
+ * 0.4.3 F1:返回值新增 `scheme`(只增不改,baseTitle/handedOff 的名字与语义不变):
91
+ * - 'handoff-n':「交接第 N 次:」;
92
+ * - 'history-n':「历史 N:」;
93
+ * - 'legacy':其余(旧「交接:」/「接棒:」堆叠、无前缀、空/脏输入回落)。
25
94
  * @param {string|undefined} title
26
- * @returns {{baseTitle:string, handedOff:number}}
95
+ * @returns {{baseTitle:string, handedOff:number, scheme:'handoff-n'|'history-n'|'legacy'}}
27
96
  */
28
97
  export function splitHandoffTitle(title) {
29
- if (typeof title !== 'string' || title.trim().length === 0) return { baseTitle: '会话', handedOff: 0 };
30
- const modern = /^交接第\s*(\d+)\s*次[::]\s*([\s\S]*)$/.exec(title.trim());
98
+ if (typeof title !== 'string' || title.trim().length === 0) {
99
+ return { baseTitle: '会话', handedOff: 0, scheme: 'legacy' };
100
+ }
101
+ const trimmed = title.trim();
102
+ const modern = /^交接第\s*(\d+)\s*次[::]\s*([\s\S]*)$/.exec(trimmed);
31
103
  if (modern !== null) {
32
104
  const base = modern[2].trim();
33
- return { baseTitle: base.length > 0 ? base : '会话', handedOff: Number.parseInt(modern[1], 10) };
105
+ return { baseTitle: base.length > 0 ? base : '会话', handedOff: Number.parseInt(modern[1], 10), scheme: 'handoff-n' };
106
+ }
107
+ const history = HISTORY_TITLE_RE.exec(trimmed);
108
+ if (history !== null) {
109
+ const base = history[2].trim();
110
+ return { baseTitle: base.length > 0 ? base : '会话', handedOff: Number.parseInt(history[1], 10), scheme: 'history-n' };
34
111
  }
35
- let base = title.trim();
112
+ let base = trimmed;
36
113
  let handedOff = 0;
37
114
  for (;;) {
38
115
  const match = /^(?:交接|接棒)[::]\s*/.exec(base);
@@ -41,5 +118,5 @@ export function splitHandoffTitle(title) {
41
118
  handedOff += 1;
42
119
  }
43
120
  const clean = base.trim();
44
- return { baseTitle: clean.length > 0 ? clean : '会话', handedOff };
121
+ return { baseTitle: clean.length > 0 ? clean : '会话', handedOff, scheme: 'legacy' };
45
122
  }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * 0.5.0:交接成功后归档旧会话的宿主接缝(best effort,绝不阻断交接)。
3
+ *
4
+ * 宿主实证(DeepSeek Harness 0.2.1-rc.3 app.asar):
5
+ * - `@deepseek-ai/dsh-workspace/lib/index.js`:`WorkspaceRegistry.archiveSession(sessionId, options)`
6
+ * 不带 `stopActivity` 时先跑 `workspace/session-activity` waterfall;任何 provider
7
+ * (运行中的 turn / subagent / job / schedule)报活都会在写盘前抛
8
+ * `WorkspaceActiveSessionError`(带 `activity` 数组)。
9
+ * - 带 `stopActivity: true` 时跳过活动检查、先写归档集合,再 `workspace/session-stop`
10
+ * 把旧会话的 turn/subagent/job/schedule 停掉。**自动交接不替用户做这个决定**:
11
+ * 静默取消后台任务比「旧会话多留一行」破坏性大得多,所以本插件永远不传
12
+ * `stopActivity`;只对「正在收尾的 turn」做有限次等待重试,仍活跃就降级为不归档。
13
+ * - `WorkspaceRegistry.unarchiveSession(sessionId)` 幂等,且不做存在性探测;
14
+ * 不在归档集合里的 id 直接 no-op。恢复入口可以放心重复调用。
15
+ * - `archivedSessionIds` 是 registry 全局归档集合;归档不改变 workspace 成员关系,
16
+ * 恢复后回到原位(dsh-workspace/lib/index.js 的注释与 README 同口径)。
17
+ *
18
+ * 插件侧访问路径:`ctx.get('workspaceRegistry')`。本插件 `lib/dsh/sessions.js`
19
+ * 建子会话时已经用同一个服务 `create(cwd)`/`attachSession(id)`,不是新接缝。
20
+ */
21
+
22
+ /** 宿主归档集合里是否有这个会话;服务不可用/形状异常 → null(不知道,不猜 false)。 */
23
+ export function archivedSessionIdsOf(ctx) {
24
+ try {
25
+ const registry = ctx?.get?.('workspaceRegistry');
26
+ const ids = registry?.archivedSessionIds;
27
+ return Array.isArray(ids) ? ids : null;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * 旧会话当前是否在归档集合里。
35
+ * @param {object} ctx
36
+ * @param {string} sessionId
37
+ * @returns {boolean|null} null = 宿主没提供可读的归档状态
38
+ */
39
+ export function isSessionArchived(ctx, sessionId) {
40
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return null;
41
+ const ids = archivedSessionIdsOf(ctx);
42
+ return ids === null ? null : ids.includes(sessionId);
43
+ }
44
+
45
+ /**
46
+ * 取 workspace registry;拿不到/没有 archiveSession 时 null。
47
+ * @param {object} ctx
48
+ * @returns {object|null}
49
+ */
50
+ export function workspaceRegistryOf(ctx) {
51
+ try {
52
+ const registry = ctx?.get?.('workspaceRegistry');
53
+ return registry !== null && registry !== undefined && typeof registry === 'object' ? registry : null;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /** 错误里的活动族(turn/subagent/job/schedule…);只用于提示,不参与归档决策。 */
60
+ function activityKindsOf(error) {
61
+ if (error === null || typeof error !== 'object' || !Array.isArray(error.activity)) return [];
62
+ const kinds = [];
63
+ for (const entry of error.activity) {
64
+ const kind = entry !== null && typeof entry === 'object' ? entry.kind : undefined;
65
+ if (typeof kind === 'string' && kind.length > 0 && !kinds.includes(kind)) kinds.push(kind);
66
+ }
67
+ return kinds;
68
+ }
69
+
70
+ function errorMessageOf(error) {
71
+ return error instanceof Error ? error.message : String(error);
72
+ }
73
+
74
+ function wait(ms) {
75
+ if (!Number.isFinite(ms) || ms <= 0) return Promise.resolve();
76
+ return new Promise((resolve) => setTimeout(resolve, ms));
77
+ }
78
+
79
+ function isActiveError(error) {
80
+ if (error === null || typeof error !== 'object') return false;
81
+ return (
82
+ error.name === 'WorkspaceActiveSessionError' ||
83
+ error.code === 'workspace/session-active' ||
84
+ Array.isArray(error.activity)
85
+ );
86
+ }
87
+
88
+ /**
89
+ * 归档交接后的旧会话(best effort)。
90
+ *
91
+ * 语义:
92
+ * - 已归档 → `already`(幂等;不重复写盘);
93
+ * - 成功 → `archived`;
94
+ * - 旧会话还有活(turn/subagent/job/schedule)→ 等短暂的空闲重试机会,仍活跃就
95
+ * `active`,**不 stopActivity**;
96
+ * - 宿主没有 registry/archiveSession → `unavailable`;
97
+ * - 宿主找不到会话 / 写盘失败 → `unknown` / `failed`。
98
+ * 任何分支都不抛错;调用方只需要把返回结果写进提示/日志。
99
+ *
100
+ * @param {object} ctx 根上下文
101
+ * @param {string} sessionId 旧会话 id
102
+ * @param {{log?:{warn?:Function}, attempts?:number, delayMs?:number}} [options]
103
+ * @returns {Promise<{status:'already'|'archived'|'unavailable'|'active'|'unknown'|'failed', archived:boolean, activityKinds:string[]}>}
104
+ */
105
+ export async function archiveHandoffSource(ctx, sessionId, { log, attempts = 2, delayMs = 150 } = {}) {
106
+ const failed = (status, activityKinds = []) => ({ status, archived: false, activityKinds });
107
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return failed('unknown');
108
+
109
+ const registry = workspaceRegistryOf(ctx);
110
+ if (registry === null || typeof registry.archiveSession !== 'function') {
111
+ log?.warn?.('compact-relay: 旧会话未归档:宿主没有 workspaceRegistry.archiveSession(交接已完成)');
112
+ return failed('unavailable');
113
+ }
114
+
115
+ // 幂等:重复交接/上一次已经归档成功时不再写盘,也不触发活动检查。
116
+ if (isSessionArchived(ctx, sessionId) === true) return { status: 'already', archived: true, activityKinds: [] };
117
+
118
+ const totalAttempts = Number.isInteger(attempts) && attempts > 0 ? attempts : 1;
119
+ let lastError = null;
120
+ for (let attempt = 1; attempt <= totalAttempts; attempt += 1) {
121
+ try {
122
+ await registry.archiveSession(sessionId);
123
+ return { status: 'archived', archived: true, activityKinds: [] };
124
+ } catch (error) {
125
+ lastError = error;
126
+ const active = isActiveError(error);
127
+ const kinds = activityKindsOf(error);
128
+ // 只对「还在跑」等一等:交接命令刚返回时,旧会话里可能有一个 turn 正在收尾;
129
+ // 默认等 150ms 再试一次,仍活跃就放弃,绝不自动 stopActivity。
130
+ if (active && attempt < totalAttempts) {
131
+ await wait(delayMs * attempt);
132
+ continue;
133
+ }
134
+ if (active) {
135
+ const label = kinds.length > 0 ? kinds.join('/') : 'active';
136
+ log?.warn?.(
137
+ `compact-relay: 旧会话 ${sessionId} 归档失败:仍有任务在跑(${label});不自动停止任务,旧会话保持可见(交接已完成)`,
138
+ );
139
+ return failed('active', kinds);
140
+ }
141
+ if (error !== null && typeof error === 'object' && error.name === 'WorkspaceUnknownSessionError') {
142
+ log?.warn?.(`compact-relay: 旧会话 ${sessionId} 归档失败:宿主里找不到该会话(${errorMessageOf(error)});交接已完成`);
143
+ return failed('unknown');
144
+ }
145
+ log?.warn?.(`compact-relay: 旧会话 ${sessionId} 归档失败:${errorMessageOf(error)}(交接已完成)`);
146
+ return failed('failed');
147
+ }
148
+ }
149
+ // 理论上循环里已经返回;留一个不抛错的兜底。
150
+ log?.warn?.(`compact-relay: 旧会话 ${sessionId} 归档失败:${errorMessageOf(lastError)}(交接已完成)`);
151
+ return failed(isActiveError(lastError) ? 'active' : 'failed', activityKindsOf(lastError));
152
+ }
153
+
154
+ /**
155
+ * 归档结果 → 交接完成 message 里的一行(成功/失败都不丢信息)。
156
+ * 成功时明确告诉用户恢复入口;失败时固定写「归档失败,不影响交接」。
157
+ * @param {{status:string, archived:boolean, activityKinds?:string[]}} result
158
+ * @returns {string}
159
+ */
160
+ export function handoffArchiveNoteOf(result) {
161
+ const status = result !== null && typeof result === 'object' ? result.status : 'failed';
162
+ if (status === 'archived') {
163
+ return '旧会话已归档(可在新会话输入框上方「已接力自 …」→「恢复旧会话」找回;侧栏视图选「全部会话」也能取消归档)。';
164
+ }
165
+ if (status === 'already') {
166
+ return '旧会话此前已在归档中(可在新会话输入框上方「已接力自 …」→「恢复旧会话」找回)。';
167
+ }
168
+ const kinds = Array.isArray(result?.activityKinds) && result.activityKinds.length > 0 ? result.activityKinds.join('/') : null;
169
+ if (status === 'active') {
170
+ return `旧会话归档失败,不影响交接(仍有任务在跑:${kinds ?? 'active'};不自动停止任务,旧会话仍在列表里)。`;
171
+ }
172
+ if (status === 'unavailable') {
173
+ return '旧会话归档失败,不影响交接(当前宿主没有 workspace 归档接口;旧会话仍在列表里)。';
174
+ }
175
+ if (status === 'unknown') {
176
+ return '旧会话归档失败,不影响交接(宿主里找不到该会话)。';
177
+ }
178
+ return '旧会话归档失败,不影响交接(旧会话仍在列表里)。';
179
+ }
package/lib/dsh/relay.js CHANGED
@@ -7,14 +7,23 @@
7
7
  */
8
8
  import { RELAY_RECORD_TYPE } from '../core/relay.js';
9
9
 
10
+ /** relay 记录里允许的标题方案(0.5.0 命名反转的两种新标题;legacy 不写进记录)。 */
11
+ const RECORD_SCHEMES = new Set(['handoff-n', 'history-n']);
12
+
10
13
  /**
11
14
  * 合法化一条 relay 记录;不合法返回 null。
12
15
  *
13
16
  * 0.4 P2-2(接力链元数据补全):canonical 字段为 `parentId` / `childId` / `ordinal` / `at` / `atIso`;
14
17
  * 同时保留 0.3 的旧字段 `parentSessionId` / `index`(旧记录只读兼容,新写入两者都带)。
15
18
  *
19
+ * 0.4.3 F2(无痕 handoff 地基):白名单放行可选字段 `scheme` / `titleSeq` / `historyTitle`。
20
+ * 旧记录没有这三个字段 → 一律读成 null;其余字段与旧记录读取语义完全不变。
21
+ *
22
+ * 0.5.0 命名反转起,父/子记录都写 `scheme`(本次链 `history-n`/`handoff-n`)与 `titleSeq`;
23
+ * 新链父记录另写 `historyTitle`(旧会话改名后的「历史 N」)。
24
+ *
16
25
  * @param {unknown} data
17
- * @returns {{parentId:string, childId:string, ordinal:number, at:number, atIso:string|null, parentSessionId:string, index:number, path?:string, planId?:string|null, title?:string, baseTitle?:string}|null}
26
+ * @returns {{parentId:string, childId:string, ordinal:number, at:number, atIso:string|null, parentSessionId:string, index:number, path?:string, planId?:string|null, title?:string, baseTitle?:string, scheme:'handoff-n'|'history-n'|null, titleSeq:number|null, historyTitle:string|null}|null}
18
27
  */
19
28
  export function normalizeRelayRecord(data) {
20
29
  if (data === null || typeof data !== 'object') return null;
@@ -43,6 +52,10 @@ export function normalizeRelayRecord(data) {
43
52
  planId: typeof data.planId === 'string' ? data.planId : null,
44
53
  ...(typeof data.title === 'string' ? { title: data.title } : {}),
45
54
  ...(typeof data.baseTitle === 'string' ? { baseTitle: data.baseTitle } : {}),
55
+ // 0.4.3 F2:新字段只做白名单放行,缺失/非法一律 null(不猜、不补默认值)。
56
+ scheme: RECORD_SCHEMES.has(data.scheme) ? data.scheme : null,
57
+ titleSeq: Number.isInteger(data.titleSeq) && data.titleSeq > 0 ? data.titleSeq : null,
58
+ historyTitle: typeof data.historyTitle === 'string' && data.historyTitle.length > 0 ? data.historyTitle : null,
46
59
  };
47
60
  }
48
61
 
@@ -63,6 +76,31 @@ export function relayRecordOf(session) {
63
76
  return null;
64
77
  }
65
78
 
79
+ /**
80
+ * 0.5.0 F4:把一条 relay 记录投影成只读的「接力链节点」(交给 readState 下发)。
81
+ *
82
+ * 只是投影,不是解析:字段全部取自记录本身,不读标题、不补默认序号、不猜 scheme。
83
+ * - 记录里没有的字段 → null(0.4 及更早的记录没有 scheme/baseTitle);
84
+ * - path 与记录写法一致:有才带,没有就不出现这个键(不塞 null 占位);
85
+ * - 不是对象 → null(表示「没有链信息」,客户端按无链接口处理)。
86
+ *
87
+ * @param {object|null|undefined} record normalizeRelayRecord 的返回值
88
+ * @returns {{ordinal:number|null, baseTitle:string|null, parentId:string|null, childId:string|null, scheme:'handoff-n'|'history-n'|null, path?:string}|null}
89
+ */
90
+ export function relayChainOf(record) {
91
+ if (record === null || typeof record !== 'object') return null;
92
+ const text = (value) => (typeof value === 'string' && value.length > 0 ? value : null);
93
+ const path = text(record.path);
94
+ return {
95
+ ordinal: Number.isInteger(record.ordinal) && record.ordinal > 0 ? record.ordinal : null,
96
+ baseTitle: text(record.baseTitle),
97
+ parentId: text(record.parentId),
98
+ childId: text(record.childId),
99
+ scheme: text(record.scheme),
100
+ ...(path === null ? {} : { path }),
101
+ };
102
+ }
103
+
66
104
  /**
67
105
  * 写一条 relay 记录;失败只返回 false(交给调用方决定是否告警),不影响交接结果。
68
106
  * @param {{appendPluginRecord?: Function}} engine