dsh-compact-relay 0.4.2 → 0.5.1
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/CHANGELOG.md +351 -0
- package/README.en.md +4 -3
- package/README.md +22 -5
- package/ROADMAP.md +44 -2
- package/TUTORIAL.md +29 -5
- package/lib/actions/handoff.js +72 -12
- package/lib/actions/pin.js +7 -6
- package/lib/actions/recover.js +49 -0
- package/lib/actions/relay-chain.js +17 -0
- package/lib/actions/state.js +137 -8
- package/lib/adapters/http.js +65 -5
- package/lib/client.js +1946 -218
- package/lib/core/clip.js +92 -0
- package/lib/core/errors.js +3 -1
- package/lib/core/native-line.js +27 -0
- package/lib/core/native-pin-risk.js +106 -0
- package/lib/core/pin-anchors.js +3 -1
- package/lib/core/pin-health.js +112 -0
- package/lib/core/relay.js +83 -6
- package/lib/dsh/compaction-log.js +16 -9
- package/lib/dsh/compaction-pair.js +27 -1
- package/lib/dsh/diag-counters.js +4 -1
- package/lib/dsh/diagnostics.js +3 -1
- package/lib/dsh/handoff-archive.js +225 -0
- package/lib/dsh/native-compaction.js +60 -4
- package/lib/dsh/relay-chain.js +238 -0
- package/lib/dsh/relay.js +147 -5
- package/lib/dsh/session-view.js +16 -10
- package/lib/dsh/sessions.js +206 -15
- package/package.json +2 -2
|
@@ -0,0 +1,225 @@
|
|
|
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
|
+
* 宿主侧栏置顶集合(0.5.1 批 2)。
|
|
156
|
+
*
|
|
157
|
+
* 宿主实证(`@deepseek-ai/dsh-workspace/lib/index.js`):`WorkspaceRegistry`
|
|
158
|
+
* - `get pinnedSessionIds()`:registry 全局置顶集合,pin 顺序(最近置顶在前);
|
|
159
|
+
* - `pinSession(id)` / `unpinSession(id)`;
|
|
160
|
+
* - 置顶与归档互斥:`archiveSession` 在**同一次写盘**里把该 id 从 pinnedSessionIds 移除
|
|
161
|
+
* (源码注释「Archiving drops the session's pin in the same durable write」)。
|
|
162
|
+
* 所以「归档会取消置顶」不是本插件的推测,是宿主真实行为;这里只读判断,
|
|
163
|
+
* 不替用户 unpin/pin。拿不到/形状异常 → null(不猜 false)。
|
|
164
|
+
* @param {object} ctx
|
|
165
|
+
* @returns {string[]|null}
|
|
166
|
+
*/
|
|
167
|
+
export function pinnedSessionIdsOf(ctx) {
|
|
168
|
+
try {
|
|
169
|
+
const registry = ctx?.get?.('workspaceRegistry');
|
|
170
|
+
const ids = registry?.pinnedSessionIds;
|
|
171
|
+
return Array.isArray(ids) ? ids.filter((id) => typeof id === 'string') : null;
|
|
172
|
+
} catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 旧会话当前是否在侧栏置顶。
|
|
179
|
+
* @param {object} ctx
|
|
180
|
+
* @param {string} sessionId
|
|
181
|
+
* @returns {boolean|null} null = 宿主没提供可读的置顶集合
|
|
182
|
+
*/
|
|
183
|
+
export function isSessionPinned(ctx, sessionId) {
|
|
184
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) return null;
|
|
185
|
+
const ids = pinnedSessionIdsOf(ctx);
|
|
186
|
+
return ids === null ? null : ids.includes(sessionId);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 归档结果 → 交接完成 message 里的一行(成功/失败都不丢信息)。
|
|
191
|
+
* 成功时明确告诉用户恢复入口;失败时固定写「归档失败,不影响交接」。
|
|
192
|
+
* @param {{status:string, archived:boolean, activityKinds?:string[]}} result
|
|
193
|
+
* @returns {string}
|
|
194
|
+
*/
|
|
195
|
+
export function handoffArchiveNoteOf(result) {
|
|
196
|
+
const status = result !== null && typeof result === 'object' ? result.status : 'failed';
|
|
197
|
+
if (status === 'archived') {
|
|
198
|
+
// 0.5.1 DCR-04:指向宿主真实的视图标签/动作文案(实测 dsh-client-ui-workspace:
|
|
199
|
+
// viewOptions 三选一里是「全部对话(显示已归档)」,会话行动作是「取消归档」;
|
|
200
|
+
// 宿主没有「全部会话」这个视图)。
|
|
201
|
+
return '旧会话已归档(可在新会话输入框上方「已接力自 …」→「恢复旧会话」找回;侧栏视图选「全部对话(显示已归档)」,再对该会话点「取消归档」)。';
|
|
202
|
+
}
|
|
203
|
+
if (status === 'already') {
|
|
204
|
+
return '旧会话此前已在归档中(可在新会话输入框上方「已接力自 …」→「恢复旧会话」找回)。';
|
|
205
|
+
}
|
|
206
|
+
// 0.5.1 批 2:用户显式关掉归档(handoff archive:false)。
|
|
207
|
+
if (status === 'skipped') {
|
|
208
|
+
return '按你的选择,旧会话未归档(仍在列表里;侧栏置顶保持不变)。';
|
|
209
|
+
}
|
|
210
|
+
// 0.5.1 批 2:旧会话在侧栏置顶,未经确认不归档(宿主归档会同时取消置顶)。
|
|
211
|
+
if (status === 'pinned') {
|
|
212
|
+
return '旧会话已置顶,本次未自动归档(归档会取消置顶;如要归档,可在交接确认里选「交接并归档(取消置顶)」,或先取消置顶)。';
|
|
213
|
+
}
|
|
214
|
+
const kinds = Array.isArray(result?.activityKinds) && result.activityKinds.length > 0 ? result.activityKinds.join('/') : null;
|
|
215
|
+
if (status === 'active') {
|
|
216
|
+
return `旧会话归档失败,不影响交接(仍有任务在跑:${kinds ?? 'active'};不自动停止任务,旧会话仍在列表里)。`;
|
|
217
|
+
}
|
|
218
|
+
if (status === 'unavailable') {
|
|
219
|
+
return '旧会话归档失败,不影响交接(当前宿主没有 workspace 归档接口;旧会话仍在列表里)。';
|
|
220
|
+
}
|
|
221
|
+
if (status === 'unknown') {
|
|
222
|
+
return '旧会话归档失败,不影响交接(宿主里找不到该会话)。';
|
|
223
|
+
}
|
|
224
|
+
return '旧会话归档失败,不影响交接(旧会话仍在列表里)。';
|
|
225
|
+
}
|
|
@@ -71,6 +71,9 @@ const CONFIG_RE = /^(\s*)config\s*:\s*(?:#.*)?$/;
|
|
|
71
71
|
const AUTO_RE = /^auto\s*:\s*(true|false)\s*(?:#.*)?$/;
|
|
72
72
|
const RATIO_RE = /^thresholdRatio\s*:\s*['"]?([0-9.]+)['"]?\s*(?:#.*)?$/;
|
|
73
73
|
const HEADROOM_RE = /^headroomTokens\s*:\s*['"]?(\d+)['"]?\s*(?:#.*)?$/;
|
|
74
|
+
/** 0.5.1 批 2:保留近期尾部的两种写法(compaction-basic 的 retainRatio / retainTokens)。 */
|
|
75
|
+
const RETAIN_RATIO_RE = /^retainRatio\s*:\s*['"]?([0-9.]+)['"]?\s*(?:#.*)?$/;
|
|
76
|
+
const RETAIN_TOKENS_RE = /^retainTokens\s*:\s*['"]?(\d+)['"]?\s*(?:#.*)?$/;
|
|
74
77
|
/** modelPolicies 列表项里的键(- provider: x / model: y / thresholdRatio: 0.5 / headroomTokens: 4096)。 */
|
|
75
78
|
const POLICY_PROVIDER_RE = /^provider\s*:\s*['"]?([^'"#\s]+)['"]?\s*(?:#.*)?$/;
|
|
76
79
|
const POLICY_MODEL_RE = /^model\s*:\s*['"]?([^'"#\s]+)['"]?\s*(?:#.*)?$/;
|
|
@@ -196,6 +199,23 @@ export function parseCompactionOverride(text) {
|
|
|
196
199
|
if (Number.isInteger(value) && value >= 0) override.headroomTokens = value;
|
|
197
200
|
}
|
|
198
201
|
}
|
|
202
|
+
// 0.5.1 批 2:原生压缩保留尾部预算(预判「会覆盖哪些 pin」用;不改原生行为)。
|
|
203
|
+
const retainRatioIndex = findDirectChild(lines, entry, config, RETAIN_RATIO_RE);
|
|
204
|
+
if (retainRatioIndex >= 0) {
|
|
205
|
+
const match = RETAIN_RATIO_RE.exec(lines[retainRatioIndex].trim());
|
|
206
|
+
if (match !== null) {
|
|
207
|
+
const value = Number(match[1]);
|
|
208
|
+
if (Number.isFinite(value) && value >= 0 && value < 1) override.retainRatio = value;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const retainTokensIndex = findDirectChild(lines, entry, config, RETAIN_TOKENS_RE);
|
|
212
|
+
if (retainTokensIndex >= 0) {
|
|
213
|
+
const match = RETAIN_TOKENS_RE.exec(lines[retainTokensIndex].trim());
|
|
214
|
+
if (match !== null) {
|
|
215
|
+
const value = Number(match[1]);
|
|
216
|
+
if (Number.isInteger(value) && value >= 0) override.retainTokens = value;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
199
219
|
return override;
|
|
200
220
|
}
|
|
201
221
|
|
|
@@ -230,7 +250,14 @@ export function parseModelPolicies(text) {
|
|
|
230
250
|
let current = null;
|
|
231
251
|
const flush = () => {
|
|
232
252
|
if (current === null) return;
|
|
233
|
-
if (
|
|
253
|
+
if (
|
|
254
|
+
current.thresholdRatio !== undefined ||
|
|
255
|
+
current.headroomTokens !== undefined ||
|
|
256
|
+
current.retainRatio !== undefined ||
|
|
257
|
+
current.retainTokens !== undefined
|
|
258
|
+
) {
|
|
259
|
+
policies.push(current);
|
|
260
|
+
}
|
|
234
261
|
current = null;
|
|
235
262
|
};
|
|
236
263
|
for (let index = start + 1; index < entry.end; index += 1) {
|
|
@@ -253,9 +280,18 @@ export function parseModelPolicies(text) {
|
|
|
253
280
|
const trimmed = line.trim();
|
|
254
281
|
const ratioMatch = RATIO_RE.exec(trimmed);
|
|
255
282
|
const headMatch = HEADROOM_RE.exec(trimmed);
|
|
283
|
+
const retainRatioMatch = RETAIN_RATIO_RE.exec(trimmed);
|
|
284
|
+
const retainTokensMatch = RETAIN_TOKENS_RE.exec(trimmed);
|
|
256
285
|
const providerMatch = POLICY_PROVIDER_RE.exec(trimmed);
|
|
257
286
|
const modelMatch = POLICY_MODEL_RE.exec(trimmed);
|
|
258
|
-
if (
|
|
287
|
+
if (
|
|
288
|
+
ratioMatch !== null ||
|
|
289
|
+
headMatch !== null ||
|
|
290
|
+
retainRatioMatch !== null ||
|
|
291
|
+
retainTokensMatch !== null ||
|
|
292
|
+
providerMatch !== null ||
|
|
293
|
+
modelMatch !== null
|
|
294
|
+
) {
|
|
259
295
|
if (current === null) current = { provider: null, model: null };
|
|
260
296
|
if (ratioMatch !== null) {
|
|
261
297
|
const value = Number(ratioMatch[1]);
|
|
@@ -263,6 +299,12 @@ export function parseModelPolicies(text) {
|
|
|
263
299
|
} else if (headMatch !== null) {
|
|
264
300
|
const value = Number(headMatch[1]);
|
|
265
301
|
if (Number.isInteger(value) && value >= 0) current.headroomTokens = value;
|
|
302
|
+
} else if (retainRatioMatch !== null) {
|
|
303
|
+
const value = Number(retainRatioMatch[1]);
|
|
304
|
+
if (Number.isFinite(value) && value >= 0 && value < 1) current.retainRatio = value;
|
|
305
|
+
} else if (retainTokensMatch !== null) {
|
|
306
|
+
const value = Number(retainTokensMatch[1]);
|
|
307
|
+
if (Number.isInteger(value) && value >= 0) current.retainTokens = value;
|
|
266
308
|
} else if (providerMatch !== null) current.provider = providerMatch[1];
|
|
267
309
|
else current.model = modelMatch[1];
|
|
268
310
|
continue;
|
|
@@ -286,8 +328,13 @@ export function parseModelPolicies(text) {
|
|
|
286
328
|
export function resolveNativePolicy(nativeCfg, route) {
|
|
287
329
|
const globalRatio = Number.isFinite(nativeCfg?.thresholdRatio) ? nativeCfg.thresholdRatio : NATIVE_DEFAULTS.thresholdRatio;
|
|
288
330
|
const globalHeadroom = Number.isFinite(nativeCfg?.headroomTokens) ? nativeCfg.headroomTokens : NATIVE_HEADROOM_DEFAULT;
|
|
331
|
+
// 0.5.1 批 2:保留尾部预算(不配置 = undefined,由 core/native-line 按宿主默认比值算)。
|
|
332
|
+
const globalRetain = {
|
|
333
|
+
...(Number.isFinite(nativeCfg?.retainRatio) ? { retainRatio: nativeCfg.retainRatio } : {}),
|
|
334
|
+
...(Number.isInteger(nativeCfg?.retainTokens) ? { retainTokens: nativeCfg.retainTokens } : {}),
|
|
335
|
+
};
|
|
289
336
|
if (route === null || route === undefined || typeof route.provider !== 'string' || typeof route.model !== 'string') {
|
|
290
|
-
return { thresholdRatio: globalRatio, headroomTokens: globalHeadroom, overridden: false };
|
|
337
|
+
return { thresholdRatio: globalRatio, headroomTokens: globalHeadroom, overridden: false, ...globalRetain };
|
|
291
338
|
}
|
|
292
339
|
const list = Array.isArray(nativeCfg?.modelPolicies) ? nativeCfg.modelPolicies : [];
|
|
293
340
|
const hit = list.find(
|
|
@@ -297,11 +344,17 @@ export function resolveNativePolicy(nativeCfg, route) {
|
|
|
297
344
|
(policy.model === route.model || policy.model === `${route.provider}/${route.model}` || policy.model === null || policy.model === undefined) &&
|
|
298
345
|
(policy.provider === undefined || policy.provider === null || policy.provider === route.provider),
|
|
299
346
|
);
|
|
300
|
-
if (hit === undefined) return { thresholdRatio: globalRatio, headroomTokens: globalHeadroom, overridden: false };
|
|
347
|
+
if (hit === undefined) return { thresholdRatio: globalRatio, headroomTokens: globalHeadroom, overridden: false, ...globalRetain };
|
|
348
|
+
const routeRetain = {
|
|
349
|
+
...(Number.isFinite(hit.retainRatio) ? { retainRatio: hit.retainRatio } : {}),
|
|
350
|
+
...(Number.isInteger(hit.retainTokens) ? { retainTokens: hit.retainTokens } : {}),
|
|
351
|
+
};
|
|
301
352
|
return {
|
|
302
353
|
thresholdRatio: Number.isFinite(hit.thresholdRatio) ? hit.thresholdRatio : globalRatio,
|
|
303
354
|
headroomTokens: Number.isFinite(hit.headroomTokens) ? hit.headroomTokens : globalHeadroom,
|
|
304
355
|
overridden: true,
|
|
356
|
+
...globalRetain,
|
|
357
|
+
...routeRetain,
|
|
305
358
|
};
|
|
306
359
|
}
|
|
307
360
|
|
|
@@ -331,6 +384,9 @@ export function readNativeCompaction(profileDir = resolveProfileDir()) {
|
|
|
331
384
|
thresholdRatio: override.thresholdRatio ?? NATIVE_DEFAULTS.thresholdRatio,
|
|
332
385
|
// 0.4 P2-3:真实触发线要用的余量与路由级覆盖(additive 字段,老调用方不受影响)。
|
|
333
386
|
headroomTokens: override.headroomTokens ?? NATIVE_HEADROOM_DEFAULT,
|
|
387
|
+
// 0.5.1 批 2:保留尾部预算(未配置时 undefined,由 nativeRetainTokensOf 按宿主默认比值算)。
|
|
388
|
+
...(override.retainRatio === undefined ? {} : { retainRatio: override.retainRatio }),
|
|
389
|
+
...(override.retainTokens === undefined ? {} : { retainTokens: override.retainTokens }),
|
|
334
390
|
modelPolicies: parseModelPolicies(text),
|
|
335
391
|
override,
|
|
336
392
|
file,
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 完整交接链的服务端读取(0.5.1 DCR-05)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:0.5.1 批 3 的「完整交接链」由客户端逐级 GET `/api/compact-relay-state?sessionId=父id`。
|
|
5
|
+
* 宿主对**没加载进内存**的会话返回 `sessionId:null`(readState 的 target 为 null),
|
|
6
|
+
* 客户端只能停在断点。重启后祖先会话通常都没加载,因此「只显示 1 层」是常态而非例外。
|
|
7
|
+
*
|
|
8
|
+
* 宿主实证(DeepSeek Harness 0.2.1-rc.3 app.asar):
|
|
9
|
+
* - `@deepseek-ai/dsh-session-persistence`:根服务 `ctx.sessionPersistence`
|
|
10
|
+
* - `stat(id)` → `{header, revision, sizeBytes?}`,**不读事件日志**;
|
|
11
|
+
* - `open(id, 'read')` → 只读 handle(不创建 live Session、不写任何状态),
|
|
12
|
+
* `handle.read()` → `{events, eventState}`,`handle.close()` 释放。
|
|
13
|
+
* - `@deepseek-ai/dsh-session-persistence-jsonl` 对**已归档**会话同样可读:归档只改
|
|
14
|
+
* Workspace registry 的集合,不动持久化文件。
|
|
15
|
+
*
|
|
16
|
+
* 所以整条链可以完全在服务端按「header + 日志尾部」读出来,不需要加载祖先会话。
|
|
17
|
+
* 这里只是只读读取:不 append、不 open 写句柄、不改归档/置顶/标题。
|
|
18
|
+
*
|
|
19
|
+
* 链接形状与客户端原逐级逻辑一致:
|
|
20
|
+
* - 每一行(祖先)的 ordinal/baseTitle/scheme 来自**子会话的入站 relay 记录**;
|
|
21
|
+
* - 交接单 path 优先用这条入站记录自己的 path,缺了再向父会话日志里找
|
|
22
|
+
* `childId===子id && ordinal===该跳序号` 的出站记录补(0.3/0.4 老记录没写 path);
|
|
23
|
+
* - 父会话自己的入站记录(或 DCR-02 老链的 header+标题兜底)继续往更早走。
|
|
24
|
+
*/
|
|
25
|
+
import { RELAY_RECORD_TYPE } from '../core/relay.js';
|
|
26
|
+
import { legacyRelayChainOf, normalizeRelayRecord, relayChainOf, relayInboundRecordOf } from './relay.js';
|
|
27
|
+
import { liveSessionOf, sessionLog } from './session-view.js';
|
|
28
|
+
import { sourceTitleOf } from './sessions.js';
|
|
29
|
+
|
|
30
|
+
/** 最多回看 8 层(与客户端 MAX_CHAIN_HOPS 同口径:防成环、防无限链)。 */
|
|
31
|
+
export const MAX_CHAIN_HOPS = 8;
|
|
32
|
+
|
|
33
|
+
/** 取宿主 sessionPersistence 服务;旧宿主/headless 测试没有 → null。 */
|
|
34
|
+
function sessionPersistenceOf(ctx) {
|
|
35
|
+
try {
|
|
36
|
+
const service =
|
|
37
|
+
typeof ctx?.get === 'function' ? ctx.get('sessionPersistence') : ctx?.sessionPersistence;
|
|
38
|
+
if (service === null || service === undefined) return null;
|
|
39
|
+
if (typeof service.open !== 'function') return null;
|
|
40
|
+
return service;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isNotFoundError(error) {
|
|
47
|
+
return (
|
|
48
|
+
error !== null &&
|
|
49
|
+
typeof error === 'object' &&
|
|
50
|
+
(error.name === 'SessionPersistenceNotFoundError' || error.code === 'session/persistence-not-found')
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 只读打开一个**未加载**的会话;返回 header + 完整事件日志(不进入 ctx.sessions)。 */
|
|
55
|
+
async function readStoredSession(ctx, sessionId) {
|
|
56
|
+
const persistence = sessionPersistenceOf(ctx);
|
|
57
|
+
if (persistence === null) return { unsupported: true };
|
|
58
|
+
let handle = null;
|
|
59
|
+
try {
|
|
60
|
+
let snapshot;
|
|
61
|
+
if (typeof persistence.stat === 'function') {
|
|
62
|
+
snapshot = await persistence.stat(sessionId);
|
|
63
|
+
if (snapshot === null || snapshot === undefined) return { missing: true };
|
|
64
|
+
}
|
|
65
|
+
handle = await persistence.open(sessionId, 'read');
|
|
66
|
+
const result = await handle.read();
|
|
67
|
+
const events = Array.isArray(result?.events) ? result.events : [];
|
|
68
|
+
const header =
|
|
69
|
+
snapshot !== null && typeof snapshot === 'object' && snapshot.header !== null && typeof snapshot.header === 'object'
|
|
70
|
+
? snapshot.header
|
|
71
|
+
: handle?.header;
|
|
72
|
+
return { id: sessionId, header: header ?? null, events, live: false };
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (isNotFoundError(error)) return { missing: true };
|
|
75
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
76
|
+
} finally {
|
|
77
|
+
if (handle !== null) {
|
|
78
|
+
try {
|
|
79
|
+
await handle.close();
|
|
80
|
+
} catch {
|
|
81
|
+
/* 只读句柄释放失败不影响读取结果 */
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 会话的链读取素材:优先 live(内存里最新,刚写入未落盘的记录也在),
|
|
89
|
+
* 否则只读持久化日志。
|
|
90
|
+
* @returns {Promise<{id:string, header:object|null, events:Array, live:boolean, session?:object}|{missing:true}|{error:string}|{unsupported:true}>}
|
|
91
|
+
*/
|
|
92
|
+
async function sessionInfoOf(ctx, sessionId) {
|
|
93
|
+
const live = liveSessionOf(ctx, sessionId);
|
|
94
|
+
if (live !== undefined && live !== null) {
|
|
95
|
+
return {
|
|
96
|
+
id: sessionId,
|
|
97
|
+
header: live.header !== null && typeof live.header === 'object' ? live.header : null,
|
|
98
|
+
events: sessionLog(live),
|
|
99
|
+
live: true,
|
|
100
|
+
session: live,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return readStoredSession(ctx, sessionId);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 会话当前标题(老链兜底合成用):live 走标题服务,持久化日志取最后一条 session/title。 */
|
|
107
|
+
function titleOf(ctx, info) {
|
|
108
|
+
if (info.live === true && info.session !== undefined) {
|
|
109
|
+
const title = sourceTitleOf(ctx, info.session);
|
|
110
|
+
if (title !== undefined) return title;
|
|
111
|
+
}
|
|
112
|
+
const events = Array.isArray(info.events) ? info.events : [];
|
|
113
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
114
|
+
const event = events[index];
|
|
115
|
+
if (event?.type === 'session/title' && typeof event.data?.title === 'string' && event.data.title.length > 0) {
|
|
116
|
+
return event.data.title;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 这一层会话「自己的入站链」:优先 relay 记录;0.5.0 之前的老链没有记录时,
|
|
124
|
+
* 用 header.parentSession + 标题(「交接第 N 次」/「历史 N」)兜底合成。
|
|
125
|
+
* 合成条件与 readState 完全一致(排除 subagent / fork / 干净标题),见 relay.js。
|
|
126
|
+
*/
|
|
127
|
+
function chainCursorOf(ctx, info) {
|
|
128
|
+
const inbound = relayInboundRecordOf({ id: info.id, log: info.events });
|
|
129
|
+
if (inbound !== null) return relayChainOf(inbound);
|
|
130
|
+
const legacy = legacyRelayChainOf({
|
|
131
|
+
session: { id: info.id, header: info.header },
|
|
132
|
+
title: titleOf(ctx, info),
|
|
133
|
+
});
|
|
134
|
+
return legacy === null ? null : relayChainOf(legacy);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 父会话日志里「交给 childId 的那一跳」的出站记录(用于补交接单 path)。
|
|
139
|
+
* 必须同时校验 childId + ordinal:父会话后来可能又交接给了下一代。
|
|
140
|
+
*/
|
|
141
|
+
function outboundRecordFor(info, childId, ordinal) {
|
|
142
|
+
const events = Array.isArray(info.events) ? info.events : [];
|
|
143
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
144
|
+
const event = events[index];
|
|
145
|
+
if (event?.type !== RELAY_RECORD_TYPE) continue;
|
|
146
|
+
const record = normalizeRelayRecord(event.data);
|
|
147
|
+
if (record !== null && record.childId === childId && record.ordinal === ordinal) return record;
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 行投影:只下发客户端画一行需要的字段(与旧逐级 GET 的 row 形状一致)。 */
|
|
153
|
+
function rowOf(sessionId, cursor, path) {
|
|
154
|
+
return {
|
|
155
|
+
sessionId,
|
|
156
|
+
ordinal: cursor.ordinal,
|
|
157
|
+
baseTitle: cursor.baseTitle ?? null,
|
|
158
|
+
scheme: cursor.scheme ?? null,
|
|
159
|
+
path: path ?? null,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 一次读出整条交接链(最多 8 层)。
|
|
165
|
+
*
|
|
166
|
+
* 不加载任何祖先会话:未加载的会话从持久化日志读 header/事件;已加载的直接用内存日志。
|
|
167
|
+
* 读不到某一层时返回**已读到的行** + `unreadable`(不是把整块清空),客户端据此显示
|
|
168
|
+
* 「上一层会话未打开」+「打开并继续」入口。
|
|
169
|
+
*
|
|
170
|
+
* @param {object} ctx 根上下文
|
|
171
|
+
* @param {{sessionId?:string, maxHops?:number}} [options]
|
|
172
|
+
* @returns {Promise<{
|
|
173
|
+
* ok:true,
|
|
174
|
+
* sessionId:string,
|
|
175
|
+
* rows:Array<{sessionId:string, ordinal:number|null, baseTitle:string|null, scheme:string|null, path:string|null}>,
|
|
176
|
+
* done:boolean,
|
|
177
|
+
* capped:boolean,
|
|
178
|
+
* unreadable:{sessionId:string, reason:'not-found'|'error'}|null
|
|
179
|
+
* } | {ok:false, code:string, message:string}>}
|
|
180
|
+
*/
|
|
181
|
+
export async function readRelayChain(ctx, { sessionId, maxHops = MAX_CHAIN_HOPS } = {}) {
|
|
182
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
183
|
+
return { ok: false, code: 'bad-session', message: '缺少 sessionId' };
|
|
184
|
+
}
|
|
185
|
+
if (sessionPersistenceOf(ctx) === null) {
|
|
186
|
+
return { ok: false, code: 'unsupported', message: '当前宿主没有 sessionPersistence,无法在不加载会话的情况下读取接力链' };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const start = await sessionInfoOf(ctx, sessionId);
|
|
190
|
+
if (start?.unsupported === true) {
|
|
191
|
+
return { ok: false, code: 'unsupported', message: '当前宿主没有 sessionPersistence,无法在不加载会话的情况下读取接力链' };
|
|
192
|
+
}
|
|
193
|
+
if (start?.missing === true) {
|
|
194
|
+
return { ok: false, code: 'not-found', message: '宿主持久化里找不到这个会话' };
|
|
195
|
+
}
|
|
196
|
+
if (start?.error !== undefined) {
|
|
197
|
+
return { ok: false, code: 'read-failed', message: `读取会话日志失败:${start.error}` };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const limit = Number.isInteger(maxHops) && maxHops > 0 ? Math.min(MAX_CHAIN_HOPS, maxHops) : MAX_CHAIN_HOPS;
|
|
201
|
+
let cursor = chainCursorOf(ctx, start);
|
|
202
|
+
let childId = sessionId;
|
|
203
|
+
const rows = [];
|
|
204
|
+
const seen = new Set([sessionId]);
|
|
205
|
+
const partial = (unreadable) => ({
|
|
206
|
+
ok: true,
|
|
207
|
+
sessionId,
|
|
208
|
+
rows,
|
|
209
|
+
done: false,
|
|
210
|
+
capped: false,
|
|
211
|
+
unreadable,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
while (cursor !== null && cursor.parentId !== null) {
|
|
215
|
+
if (rows.length >= limit) {
|
|
216
|
+
return { ok: true, sessionId, rows, done: true, capped: true, unreadable: null };
|
|
217
|
+
}
|
|
218
|
+
const parentId = cursor.parentId;
|
|
219
|
+
// 链路成环/自指:去重后停在最后一条真实会话,不再重复列同一层。
|
|
220
|
+
if (seen.has(parentId)) {
|
|
221
|
+
return { ok: true, sessionId, rows, done: true, capped: false, unreadable: null };
|
|
222
|
+
}
|
|
223
|
+
const info = await sessionInfoOf(ctx, parentId);
|
|
224
|
+
if (info?.missing === true) return partial({ sessionId: parentId, reason: 'not-found' });
|
|
225
|
+
if (info?.error !== undefined) return partial({ sessionId: parentId, reason: 'error' });
|
|
226
|
+
if (info?.unsupported === true) {
|
|
227
|
+
return { ok: false, code: 'unsupported', message: '当前宿主没有 sessionPersistence,无法在不加载会话的情况下读取接力链' };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const outbound = outboundRecordFor(info, childId, cursor.ordinal);
|
|
231
|
+
rows.push(rowOf(parentId, cursor, cursor.path ?? outbound?.path ?? null));
|
|
232
|
+
seen.add(parentId);
|
|
233
|
+
childId = parentId;
|
|
234
|
+
cursor = chainCursorOf(ctx, info);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return { ok: true, sessionId, rows, done: true, capped: false, unreadable: null };
|
|
238
|
+
}
|