Repository navigation
Releases
v0.4.0-s2s.14
fix-rename-invisible-local-cache
删除本地标题缓存:它让被改名的会话永久保留旧名
症状
某会话早已改名(持久 session/title 事件已写入),宿主的投影行也已带上新名,但 s2s 的列表与按名寻址仍然报旧名 —— 观测到的一次陈旧约 9 小时,且不会自行恢复。
根因(两处叠加,都在本插件)
- 本地缓存遮蔽了宿主的投影缓存。
collectFromQuery先查本地缓存,命中即continue,从不询问宿主;resolve()还有两处只查本地缓存的快路径。于是权威源(L1,零 I/O、带seq)被一个更陈旧、且我们无法感知上游变更的副本盖住。 - TTL 是滑动窗口。命中会刷新
lastAccess(now - lastAccess > TTL判过期),因此被频繁查询的会话永不满足过期条件 → 陈旧不是"最长 24 小时",而是热点永久。
根本问题不在 TTL 取值,而在于我们在没有变更流的情况下复制了宿主的缓存:改名来自 GUI、标题 provider、fallback,没有任何一条会通知我们。
修复
删除本地标题缓存,让宿主的投影行成为唯一来源。
- 删掉
src/title-cache.ts(243 行)及其在discovery.ts的全部接线(含cachePath/now配置与只服务于它的抽象); - 读取层序由
L0 → L1 → L3变为L1(cachedSnapshot ?? cachedPredecessorTitle) → L3; - 删掉
resolve()的两处本地快路径 —— 每次按名寻址都枚举并读宿主行; forgetCachedTitle()保留为安全的空操作(无缓存可失效),避免破坏调用方。
为什么现在可以删
本地缓存当初是为省下"L1 未覆盖时的逐条 readTitle"而引入。cachedPredecessorTitle 接上后,该回落已从 122/238 降到 8/238 —— 它要解决的问题基本消失了,而它自己制造的缺陷是永久性的。
验证
- 回归测试有牙:新增"改名须立即可见"与"旧名须停止解析"两条;临时植入一个遮蔽式本地记忆后,恰好这两条变红,移除后全绿;
- 另增"每次按名寻址都重新枚举"(断言
listSessions调用次数)与"首次命名稍后出现也能被拾取"; - L1 层与前任 face 的既有测试全部保留;全量 119 测试通过,typecheck 与构建(含产物标记门禁)通过。
代价(诚实)
未被宿主投影行覆盖的会话,每次枚举都要付一次逐条读取(实测约 8/238)。这是为"名字正确"付出的确定成本,换掉的是一整类永久错误。
v0.4.0-s2s.13
support-dsh-0.2-peer-range
兼容 dsh 0.2.x:放宽 peer 范围,并修复"裸挂载即崩溃"
问题
-
peer 范围排除了 0.2.x:五个
@deepseek-ai/dsh-*peer 声明为^0.1.0-rc.6(>=0.1.0-rc.6 <0.2.0),于是运行在 0.2.x 上的 harness 在安装/启动阶段直接判定为不兼容并禁用整行:Plugin dsh-s2s@… is incompatible with dsh 0.2.0-rc.1: peerDependencies {…} -
裸挂载必然崩溃:
apply(ctx, config)未给config默认值,而if (config.lifecycle !== undefined)直接解引用。任何不声明config块的 profile 行都会得到:TypeError: Cannot read properties of undefined (reading 'lifecycle')这与该函数自己的文档相反 —— 文档写着 "a bare mount is just the broker + discovery + tools",而裸挂载恰恰是唯一无法挂载的形态。
修复
- 五个 peer 放宽为
^0.1.0-rc.6 || ^0.2.0-rc.1,同时保留 0.1.x 支持,不使既有部署失效。@deepseek-ai/cordis: ^4.0.1未变(0.2.x 运行时自带 4.0.4,本就在范围内)。 apply(ctx, config: Config = {})—— 裸挂载恢复为受支持形态。
为什么只改版本号不够
改完 peer 后实际加载验证发现第二个缺陷:在隔离实例里 dsh-s2s 不再被拒,但 apply 立刻抛 TypeError。仅凭"版本号已匹配"就宣称修好会把一个必然崩溃的插件放进运行时。两个缺陷是叠加的,必须一起修。
验证
- 隔离实例加载:在 0.2.x 运行时下用真实 profile 配置启动,
dsh-s2s零告警、零禁用、零TypeError,did not activate计数为 0。 - API 表面核对:本插件 import 的全部 9 个符号(
Service/Context/SessionId/createUserMessage/HarnessError/BlockAssembler/installModelSelection/Agent/ModelSelection)在 0.2.x 里均存在,相关包导出清单仅有新增、无删除。 - 回归测试:新增 2 条裸挂载用例(不传 config、显式传
undefined)。二者在修复前均为红。全量 127 测试通过。
v0.4.0-s2s.9
fix-short-id-display
修复:会话短 id 显示成 session-
会话 id 的规范形态是 session-<uuid> —— ctx.agents.get(SessionId(...))、mailbox 路径、registry.resume({ resumeSessionId }) 三者都要求这一形态,因此工具层从不改写它。
但短 id 的显示直接对原始字符串做 slice(0, 8),于是 uuid 从第 8 个字符才开始 ⇒ 每个有标题的会话在 s2s_sessions / s2s_peers / 候选列表 / s2s_schedule 里都显示成 [session-],完全看不出是哪个会话。
修复
新增 shortId():显示前先剥掉 session- 容器前缀再取前 8 位,四处显示点全部改走它。同时把 not-found 提示里"改名立即生效"的旧说法改为"改名在下一个标题检查点后生效",与缓存语义一致。
测试
新增 5 条用例,用带前缀的真实 id 形态断言 [094e7104] 且不得出现 [session-],并单独断言交给 broker 的 id 仍是完整规范形态(显示归一化不得泄漏到执行路径)。这 5 条在旧实现下 4 条为红,证明它们真能抓住该缺陷。
存在的理由:此前所有测试用的都是手写的不带前缀 id(rec('a', '/w')),mock 形态与真实数据形态不一致,所以这个缺陷长期未被发现。
v0.4.0-s2s.8
refactor-drop-batch-title-layer
简化:去掉无加速效果的批量层,并对齐标题新鲜度说明
对分层标题解析做了一次整体 review,结果落到三处改动。
1. 移除 readTitleSnapshots 批量层(实测不加速)
原实现把宿主批量 API 当作 L2 加速层。实测否决:同一份 238 会话的真实语料上
| 方式 | 耗时 |
|---|---|
逐个 readTitle(每会话 load + fold 整份日志) |
22,305 ms |
一次 readTitleSnapshots(单次 projectMany) |
22,567 ms |
无差别 —— projectMany 每个会话仍要 load 整份日志再 foldSessionTitle,批量只是把 N 次
API 调用合成一次,不减少工作量。因此该层连同其从未被传入的参数一起删除;真正的加速只有两条:
宿主的零 I/O 投影缓存(0.1.7+,实测 207 ms)与本地缓存(命中 3 ms,resolve(name) 1 ms),
对比旧实现的 22.3 s/次且不缓存。
分层现在是 L0 本地缓存 → L1 宿主投影缓存 → L3 逐条读取 → L4 磁盘扫描,每层仍按能力探测下落,
0.1.5 及以下宿主不受影响。
2. 标题新鲜度说明与实际行为对齐(契约变更)
README / USAGE / BEST_PRACTICES 此前都写着「每次解析现读最新标题、改名即刻生效、绝不缓存」。
引入缓存后该说法已不成立。三处统一改为:标题读自缓存,改名在下一个标题检查点后生效,并给出
24h 新鲜度上限;需要立刻点名刚改名的会话时用 session_id。
这是一次有意的契约放宽,换来从"每次枚举都逐会话读日志"到"零日志解码"的差别。
3. 测试
批量层相关断言改写为新语义(L1 miss → 仅为 miss 的那些会话各做一次单条读取,且不再触碰任何批量 API)。
其余覆盖不变:命中零读取、E1–E4 惰性失效、仅改文件不重解、跨重启持久化、L1 命中/无标题行即答案/
抛错不破坏枚举、pre-0.1.7 宿主经 L3 仍可用、按 id 零枚举、缓存文件损坏与版本不符容错。
全部 115 测试通过。
v0.4.0-s2s.7
perf-title-resolution-layered-cache
性能:标题解析 16.9 s → 3 ms(缓存 + 分层能力探测)
问题
s2s_message / s2s_sessions 的寻址(name → sessionId)每次都要枚举整个会话语料并逐个解包日志取标题,且结果完全不保留。实测:233 个日志文件、855,034 个 zstd 帧,一次全量解码 9.1 s;discovery.list() / resolve(name) 实测 16.6 s / 16.8 s,第二次调用同样耗时。
投递本身是进程内同步直投,所以一次 s2s 调用的开销几乎全在寻址上,且随语料规模线性复利。
修复:能力探测的分层解析
按代价从低到高,每层用 typeof === 'function' 探测,缺失即整层跳过并下落:
| 层 | 手段 | 成本 | 适用宿主 |
|---|---|---|---|
| L0 | 本地标题缓存(内存 Map + 持久 JSON 文件) | ~0 | 所有版本 |
| L1 | sessionProjectionCache.cachedSnapshot(header, ['title']) 零 I/O 列举读 |
~0,同步 | 0.1.7+ |
| L2 | readTitleSnapshots(ids) 一次语料观测 |
一次观测 | 0.1.7+ |
| L3 | 逐条 readTitle(id) |
O(N) | 0.1.5 及以下 |
| L4 | 磁盘扫描 + 多帧 zstd 解码 | O(N) 次(仅首次) | sessionQuery 缺席 |
L0 是唯一跨版本通用的加速层 —— peer 范围声明到 ^0.1.0-rc.6,老宿主没有 L1/L2,但一样受益。L3/L4 完整保留,是那些版本的唯一路径。
L0 的失效模型:惰性、事件驱动
日志文件变化不触发重解。 仅在下列事件发生时才重读:
- E1 找不到(未缓存)
- E2 找错了(同名冲突,记为 ambiguous,不再由缓存单独作答)
- E3 被主动要求重找(
forgetCachedTitle) - E4 条目超过 24h 未访问
代价(已接受):会话改名后旧名会继续命中,直到触发 E1–E4 之一。按 session id 解析永不受影响(id 不可变),且陈旧条目不会指向无关会话。
持久化的启用条件
仅当部署显式声明了 DSH 主目录(DSH_HOME / DSH_DATA_DIR)时才落盘;未声明时退化为纯内存缓存。这样单元测试与临时嵌入不会写入用户真实的 ~/.dsh。
实测收益(真实语料:238 会话)
| 路径 | 耗时 |
|---|---|
| L4 冷启动(逐帧解码全部日志) | 16,935 ms |
| L4 热启动(L0 缓存命中) | 3 ms |
resolve(name) 经 L0 缓存 |
0 ms |
| L1(投影缓存) | 3 ms |
标题结果完全一致(156/238),纯加速、无语义变化。
测试
新增 16 条用例,覆盖:缓存命中零读取、E1/E2/E3/E4 四种失效、仅改文件不重解(锁定惰性语义)、跨重启持久化、L1 命中/L1 miss→L2 单次批量/L1 无标题行也算答案/L1 抛错不破坏枚举、pre-0.1.7 宿主(无投影缓存且无批量 API)经 L3 仍可用且 L0 生效、按 id 解析零枚举,以及缓存文件损坏/版本不符的容错。
全部 115 测试通过。
v0.4.0-s2s.6
fix-v4-producer-source-kind
修复:v4 会话格式拒绝消息,报 producer-owned source kind
DSH 0.1.7-rc.2 的会话格式 v4 在写入admission 就拒绝 { kind: 'plugin', … }:
Error: format v4 message requires a producer-owned source kind
校验点(dsh-session-persistence-jsonl/lib/worker.cjs,写入路径 assertV4MessageSources):
if (!isJsonObject(v) || typeof v.kind !== 'string' || v.kind.length === 0
|| v.kind === 'plugin')
throw new SessionFormatError("format v4 message requires a producer-owned source kind")v4 把「插件所有权包装」退休了,每个生产者必须在自己的模块里声明 kind(类型定义原话:"each producer declares its own kind in its own module; there is no shared catch-all plugin kind"),官方插件已全部改完:schedule / plan-mode / time-context …
本插件的缺陷
三处注入都在用已废弃的包装,于是每一次投递都让目标会话整轮 turn 打挂 ——s2s_message、s2s_resume、s2s_schedule 全部命中:
source: { kind: 'plugin', plugin: 'dsh-s2s' } // 旧
source: { kind: 'dsh-s2s' } // 新失败时机:turn 的第一个落盘事件 agent/inbox/spliced 携带该消息 → admission 不过 → 整批不写。所以目标会话日志会干净地停在上一轮 turn/end,连 turn/start 都不留;会话文件本身不受损,可直接打开,但任何 s2s 投递都会被立刻打回。
修复
- 新增
src/source.ts:按官方模式做声明合并,并导出单一常量
S2S_MESSAGE_SOURCE = { kind: 'dsh-s2s' }(与dsh-schedule的runtime.d.ts同构)。 broker.ts/lifecycle.ts/schedule.ts三处改为引用该常量,消除重复引入旧写法的可能。types.ts里把旧约定("use the built-inpluginsource kind")的注释改为现状说明,避免后人回退。
测试
- 既有断言更新为
kind === 'dsh-s2s',并断言plugin字段不存在。 - 新增 2 条回归断言,分别钉住 live 投递(
broker)与静止会话 drain(lifecycle)两条路径,
明确断言kind !== 'plugin'—— 这条规则一旦回退,整个 s2s 投递面立刻全废。
验证
- 99 测试通过,
tsc --noEmit干净(借助声明合并,不再依赖已被移除的plugin成员)。 - 端到端:构建产物的
S2sBroker实际投递得到的 source 为{"kind":"dsh-s2s"},
通过 0.1.7 的真实 admission 规则;旧的{kind:'plugin',plugin:'dsh-s2s'}被拒。
v0.4.0-s2s.5
fix-zstd-multiframe-title
修复:多帧 zstd 会话日志的标题读取
collectFromFs() 的 FS 兜底路径此前读不到任何磁盘上的会话标题。
根因:DSH 的 session.jsonl.zstd 是追加写的,每帧一次追加,真实日志动辄上万帧;而原 decompressZstdAll() 用单个 createZstdDecompress() 流吃下整个 buffer,只解出第一帧、静默丢弃其余,且不报错。实测一个 28.9 MB / 81282 帧的会话,只解出 196 字节。
后果:当 sessionQuery 不可用时,按名字(session title)解析全部失效 —— 只能靠 session_id 定位。
修复
- 新增
scanZstdFrames():按 zstd 帧结构(magic + frame header + block header)扫描帧边界,不解码 block;对崩溃截断的撕裂尾帧返回tornStart而非抛错,已写帧仍可用。 - 新增
latestTitleFromZstd():逐帧zstdDecompressSync()解码,只保留最后一个session/title;日志不再整体膨胀成一个巨型字符串。 - 结构性损坏的完整帧仍然抛错 → 调用方降级为"无标题",保持原有 all-or-nothing 语义。
效果
在本机 235 个会话 / 537 MB 语料上实测:可读标题 0 → 156。
测试
新增 3 个用例,多帧输入下旧实现必然失败:
- 多帧取最后一帧的标题(旧实现只读到第一帧)
- 512 帧日志仍能取到末帧标题
- 末帧被截断时,回退到最后一个完整帧的标题
此前 3 个既有用例全部用
zstdCompressSync单帧输入,因此该 bug 长期未被 CI 发现。
CI
新增 .github/workflows/ci.yml:push / PR 到 main 时在 Node 24 上执行 typecheck → test → build。
v0.4.0-s2s.12
fix-stale-bundle-build
修复:构建产出陈旧产物(tsc 被漏跑),导致多个修复根本没有进入 bundle
症状
连续三个修复(短 id 显示、缓存落盘、L1 前任 face)提交、CI 通过、Release 正常,但宿主重启后行为毫无变化 —— 看起来像"代码不对",实际是产物根本没更新。
根因
tsdown 打包的输入是 lib/types/**(tsc 的产物),不是 src/。因此:
tsdown → 把上一次 tsc 的旧结果重新打包一遍
tsc && tsdown → 正确
我在若干次构建中只跑了 tsdown,于是每一次都重新发出了上一版的 bundle。因为产物确实被"重新生成"了(时间戳变新、大小略变),所以肉眼与 CI 都看不出问题。实测确认:lib/index.js 里 cachedPredecessorTitle 标记数为 0,而源码里存在。
修复
package.json的build改为tsc -p tsconfig.json && tsdown && node scripts/verify-bundle.mjs—— 单一入口,顺序不可能再错;- 新增
scripts/verify-bundle.mjs:构建后断言 bundle 含有一组"只有对应源码入选才存在"的标记(cachedPredecessorTitle/shortId/onPersistError/dsh-s2s),缺一即以非零码失败,并提示"是不是漏跑了 tsc"; - CI 已在跑
pnpm run build,因此这条断言进入流水线 —— 陈旧产物今后会在 CI 就被拦下。
意义
这是本仓库第二次因"产物形态 ≠ 真实形态"而长期掩盖问题(前一次是 zstd 测试只用单帧输入)。区别是这次加上了机械化门禁,而不是靠人记得跑对命令。
v0.4.0-s2s.11
fix-l1-predecessor-face
修复:L1 只用了投影缓存的一半,导致约一半会话回落到逐条日志读取
症状
s2s_message / s2s_resume 在生产稳定耗时 4.7 s / 3.5 s,且第二次调用同样慢(缓存已落盘也不改善)。
根因
宿主的会话列表(dsh-api-session-controller/lib/index.js:2010-2012)查的是两个 face:
seq = sessionProjections.cachedSnapshot(session) // live 会话
cached = cache.cachedSnapshot(header) ?? cache.cachedPredecessorTitle(header)本插件只用了第一个 cachedSnapshot(header)。当前检查点没有 title 行的会话因此全部落到 sessionQuery.readTitle(id) —— 那是一次完整日志读取 + 解压 + fold。
实测(238 会话语料):单次 readTitle 39.4 ms;4.7 s ÷ 39.4 ms ≈ 119 次回落,与"约一半会话无当前检查点行"吻合。这也解释了为什么 L0 缓存落盘后仍然慢 —— 它缓存的是读取结果,而未命中是结构性的,每次都要重付。
修复
readTitleFromL1 改为与宿主同序的两级:
projection.cachedSnapshot(meta, ['title'])
?? (typeof projection.cachedPredecessorTitle === 'function' ? projection.cachedPredecessorTitle(meta) : undefined)接口 SessionProjectionCacheLike 增加可选 cachedPredecessorTitle,缺失即跳过(能力探测,老宿主不受影响)。
测试
新增 3 条:①当前检查点无行时由 cachedPredecessorTitle 作答且零逐条读取;②前任行无标题也算答案,不算 miss;③两个 face 都不作答时才回落读取,且只读那一个。全部 125 测试通过。
诚实声明
本次未测 sessionQuery.listSessions()(枚举 238 个会话)自身的成本。若它本身就耗时数秒,则本修复只消除"回落读取"那部分,总耗时会低于 4.7 s 但未必到 ms 级。下次实测应把这两项分开计时。
v0.4.0-s2s.10
fix-title-cache-persistence
修复:标题缓存从未落盘,导致每次寻址重付全部未命中读取
症状
s2s_message / s2s_resume 在生产实测 4.7 s / 3.5 s,而隔离基准里同一路径是 4 ms —— 差三个数量级。
根因(实测,两处叠加)
- 单次真实
readTitle= 39.4 ms(读日志 + 解压 + fold),语料 238 个会话。反推 4.7 s ≈ 119 次未命中读取,3.5 s ≈ 89 次 —— 即宿主的零 I/O 投影缓存只覆盖了一部分会话,其余每次都回落逐条读取。 - 本地缓存(L0)本该把"未命中"变成只付一次,但它从未落盘。 两个缺陷:
defaultCachePath()在DSH_HOME缺失时返回undefined,于是缓存静默退化为纯内存。该判据与官方dsh-home-paths的resolveDshHome语义不一致 —— 官方是"env 缺失则回落~/.dsh",而本实现是"缺失则不持久化"。flush()用空catch吞掉所有写入错误。实测该路径上会得到EPERM(缓存目录不在可写根内),但错误既不上报也不重试,于是这一层彻底变成 no-op,且无人知道。
修复
defaultCachePath()对齐官方语义:DSH_HOME/DSH_DATA_DIR非空则用之,否则回落~/.dsh。不再返回 undefined,持久化始终启用。flush()的失败改为一次性上报(可注入onPersistError;S2sDiscoveryService接到ctx.logger.warn),并置persistFailed停止后续重试 —— 避免每次调用都去撞同一堵权限墙。TitleCache增加persistenceUnavailable只读判定,便于工具与测试观察该状态。
测试
新增 2 条:①写入被拒时只上报一次、之后不再重试,且内存读取仍可用;②DSH_HOME 缺失时路径解析不抛错且非 undefined(钉住"绝不退化为纯内存")。全部 122 测试通过。
仍未解决的前提
缓存目录必须可写。实测本机 ~/.dsh/s2s/ 的写入会得到 EPERM(不在可写根列表内)。邮箱(~/.dsh/s2s/mailboxes)历史上成功写入过,说明宿主进程对该目录可能有写权限,但未能证实。若宿主同样被拒,则日志会出现 title cache is not writable 警告,此时需把 ~/.dsh 加入部署的额外可写根。