← 回到 infra 主页

DeepSeek Harness · Upstream · Issue #8 草案

会话列表 session/list 是 fail-closed 的全量扫描:一个脏目录就让整张列表消失(「历史 session 不见了」)

先说结论:用户报的「历史会话不见了」不是数据丢了,而是侧边栏整张列表唯一的数据来源——session/list 这一次 RPC——抛了或者慢到被一轮超时打断。同一条接口在 Host 侧是「逐个项目目录、逐个会话目录 readdir + 解压每个会话日志首帧读 header」的全量扫描,而且全有或全无:命中七类脏状态中的任意一类,整张列表一起抛,前端拿到的就是空列表(工作区还在、会话全没了);下一次成功时它又「自己好了」——这正是它「经常出现又说不清」的原因。本页给出可跑的复现 oracle、可应用的最小补丁,以及可直接粘贴的英文 issue 正文。

一句话判据(现场 30 秒分清是不是丢数据):登记表(storages/workspace.json 的 sessionIds)里的会话一条都不许缺地出现在 session/list 的返回里。缺 = 这次调用真的没把列表交出去(数据仍在盘上);不缺而看不见 = 只是前端三层折叠(每工作区默认 5 条 / subagent 不显示 / archive 永久隐藏)。
优先级:高。①用户每天都会撞(打开页面就看得到);②规模在涨——本部署盘上会话目录 2935 个、每天 +70,其中 2824 个(96%)是侧边栏根本不渲染的 subagent 会话,列表成本随它们线性上升;③「编码检查失败被按进程缓存」那一条是不可自愈的,只能重启。

一句话结论与三条硬事实

三条都是实测或源码可验的,不依赖任何猜测。

① 列表是一次全量扫描,成本 O(盘上全部会话)

实测一次真实调用:2957 行 / 1.28 MB / 9.7–11.9 秒。链路是 session/list → sessionQuery.listSessions() → persistence.list() → listArtifacts():逐个项目目录(workspace slug)、逐个会话目录 readdir,再解压每个会话日志首帧读 header,最后把所有会话(含 origin=subagent,前端并不渲染)序列化给浏览器。没有分页、没有 limit、没有缓存。

② 这条链路是 fail-closed:一个脏目录 = 全部工作区的全部会话一起消失

listArtifacts() 的 catch 里只放过 SessionFormatUnsupportedError,其余一律 throw。而同一个函数往下三行,header 解不出来却是 if (header === void 0) continue(只跳那一行);同一个仓另一处也早就 fail-soft(api-session.list: projection column ... failed; serving the row without it)。也就是说:这里的「宁可整张列表不发」既不一致、也不是有意为之的取舍。

③ 一次性的编码检查失败会被进程缓存,重启前不可恢复

ensureRootEncoding() 用的是 this.rootEncodingCheck ??= this.checkRootEncoding():rejected 的 promise 也被 ??= 记住。一次瞬时或一次性的「同目录混用两种压缩编码」状态,会让该进程此后每次列表都失败,直到 systemctl restart。这在「本地 CLI、进程短命」的假设下无所谓;在 7×24 长驻的 Web 服务上就是缺陷。

实测锚点(同一实例、同一份数据)

全部为 2026-09-13 当天在生产实例上的读数,命令可复跑;数字是活账,提交 issue 前请以当刻读数为准。

观测值
真实通路 POST /api/session/list(经本机网关、带真实 Cookie)2957 行 / 1.28 MB / 9.7 s、9.7 s、11.9 s
同一条 RPC 改为定时探针连打(不同时段)6.2 s / 7.3 s / 9.9 s / 1.9 s——同一实例、同一份数据,摆动约 5×,纯负载相关
盘上会话目录(单户工作区 [repo-slug])2935 个;其中 origin=subagent 2824、用户主会话 111
每天新增会话目录约 +70(09-13 当天 71)
登记 ↔ 列表对账(完整性判据)登记 111 条全部在列表里(missing=0);archivedSessionIds 8 条仍在列表(前端隐藏)
静态体检(七类脏状态,7 户 3512 个会话目录)SESSION_LISTING_INTEGRITY=PASS fatal=0 warn=0——当刻数据是干净的,所以现象是「时有时无」而不是稳定复现
上游版本核对dsh-session-persistence-jsonl 与 dsh-api-session-controller 在 0.1.5-rc.1(latest)与 0.1.5-rc.2(next)之间 diff = 0 行:最新发布版一条都没修

与 issue #7 的关系:同一接口的两种失效形态

两页说的是同一个 RPC 的两个不同故障面,不要混为一条。

#7(2026-09-08 草案)#8(本页)
现象请求永不返回:零字节、连响应头都没有(≤300 s)请求返回失败或极慢:RPC 报错 / 超预算,前端拿到空列表
用户看到页面永远「连接中」/ 列表转圈工作区在、会话列表空;刷新或稍后又全回来
可自愈否(挂住的那条请求要等超时/重连)多数可(下一次成功即恢复);编码检查缓存中毒那类不可,只能重启
共因这条接口在 Host 侧是全量扫描 + 无分页/无 limit/无缓存/无耗时预算,而它恰好是侧边栏唯一的数据来源
本次新增的判据探针超时(挂起)探针断言 ok=false 或「登记会话缺行」(见 §8)

代码级证据:四条缺陷(指到行)

行号按随包发布的 lib/index.js(0.1.5-rc.1 与 rc.2 逐字节相同);上游 TS 源码里的同名函数即对应锚点。

#缺陷证据
①列表枚举 fail-closed:任一目录脏 → 整张列表抛listArtifacts():catch (error) { if (error instanceof SessionFormatUnsupportedError) continue; throw error; };同函数三行后 if (header === void 0) continue(该行可跳);同仓 api-session.list 对投影列也 fail-soft
②失败被按进程缓存(sticky)ensureRootEncoding():this.rootEncodingCheck ??= this.checkRootEncoding();——rejected promise 同样被缓存
③网页面 session/list 无 cursor/limit/缓存dsh-api-session-controller 的 async list(signal):不接 limit/cursor,全量 → 排序 → 返回;而同仓 ACP 面的 session/list 有 keyset cursor(decodeSessionListCursor / isAfterSessionListCursor)——会做分页,只是没做到网页面上
④索引基建已有、未接上dsh-session-query-sqlite 维护 persisted_sessions(id, revision, generation) 派生索引表,却 0 处覆盖 listSessions(继承基类的全量扫描);默认组合还把它挂在 path: ':memory:', openAt: never
另一条同族缺陷(已单独成型):同目录混用两种压缩编码时抛 encodingMismatch,并因 ② 被缓存——这就是「不可自愈」的那一类。补丁第 3 处专门修它。

七类脏状态:命中任一 = 整张列表抛

全部来自随包发布的读盘代码,不是臆测;每一类都在 oracle 里有对应夹具与断言。

类状态抛出点
F1项目目录下平铺 .jsonl / .jsonl.zstd 文件listSessionDirs() → legacyLayout
F2会话目录里混用另一种压缩编码(本机 zstd,却出现未压缩 session.vN.jsonl)resolveGenerationInDirectory() → encodingMismatch,并被 ② 缓存
F3文件名代际与 header 的 version 不一致readGenerationHeader() → 「generation filename identifies vN, but its header identifies vM」
F4aheader 缺 cwd(会被读成 _no-cwd)assertStoredIdentity() → 路径互指失败
F4bheader 的 cwd/id 与该文件所在路径不互指同上
F5header 带退役策略字段(sandboxMode / approvalPolicy)「session header uses retired policy baseline fields」
F6同一 header id 出现在两个项目目录「duplicate JSONL session id ... appears in multiple project directories」
为什么会撞上:这些状态平时不存在(今天 7 户 3512 个目录全部 fatal=0),但它们全都是我们自己的运维动作可能引入的——一次会话目录搬迁、一次恢复/备份回灌、一次手工迁移脚本、一次磁盘满导致的半截写入。缺陷不在「有人弄脏了一个目录」,而在「弄脏一个目录的代价是整站列表消失」。

复现:可跑的 oracle + 可应用的补丁

材料在本仓 ops/dsh/upstream-issue-session-list-scan/;oracle 无外部依赖,只判断言,退出码 0 = 全过。

oracle(BUGGY vs PATCHED 双跑)

node ops/dsh/upstream-issue-session-list-scan/selftest.mjs
# -> SELFTEST PASS, exit 0

它证明四件事:(1) 七类脏状态在 BUGGY 下各自都让整张列表抛(0 行可见);(2) PATCHED 下只消失那一行,并恰好多出一条指名路径的告警,其余 5 条照常渲染;(3) ensureRootEncoding 的失败在 BUGGY 里粘住整个进程(脏文件清掉后再调仍然拒绝),PATCHED 立即恢复;(4) 成本形状:无 limit 时列出 200 行要走遍 200 个目录,带 limit=50 的服务端实现只走 50 个。

补丁(最小三处,已在随包文件上验过可应用、可解析)

cat   ops/dsh/upstream-issue-session-list-scan/upstream-session-list-fail-soft.patch
#     git apply --check ...   # 对 node_modules/@deepseek-ai/dsh-session-persistence-jsonl/lib/index.js
#     node --check ...        # 打完补丁的文件语法仍合法(我们两个都跑过)

三处改动:① listSessionDirs():项目目录下的平铺 legacy 文件告警 + 忽略,不再为它把整个根抛掉(它天然在「按目录兜底」的范围之外——这是 oracle 反过来纠了补丁设计的一处);② listArtifacts():按目录 try/catch,出错目录跳这一行 + 告警指名路径,SessionFormatUnsupportedError 保持静默跳过、abort 信号照旧生效;③ ensureRootEncoding():只缓存成功,失败即丢弃。

建议修法:最小三处 + 两条建议

最小补丁只解决「一个目录弄脏整站列表消失」;后两条是规模问题,建议与修复分开推进。

项内容
必做(已在补丁里)按目录 fail-soft(跳过 + 告警指名路径);legacy 平铺文件告警而非整根抛;ensureRootEncoding 失败不缓存。与同仓既有 fail-soft 先例一致。
建议一:给列表分页网页 session/list 加 limit/cursor,直接复用 ACP 面已有的 keyset cursor 实现(createdAt + sessionId)。这是唯一能把「每轮列表 = O(全部会话)」降下来的服务端改法。
建议二:吃已有索引列表改由 dsh-session-query-sqlite 已有的 persisted_sessions(id, revision, generation) 派生索引驱动(按 revision 增量刷新),而不是每次全量 readdir + 解压首帧。
建议三:子代理会话保留期给 origin=subagent 的会话一个可配置保留期/GC(本部署 96% 的目录是它,且侧边栏不渲染)。这一条是产品取舍,需要用户拍板,我们不会静默删数据。

生产侧兜底:我们已经做了什么

不依赖上游合入;四层都是本仓代码,各有自证与判定行。

层载体判据
静态体检(七类脏状态)ops/dsh/verify-session-listing-integrity.pySESSION_LISTING_INTEGRITY=PASS users=7 dirs=3512 fatal=0 warn=0
端到端探针(走真实通路,断言登记会话一条不缺 + 时延预算)ops/dsh/session-list-health.pySESSION_LIST_HEALTH_PASS users=7 worst=<user>/<ms>
常驻哨兵(每 2 分钟)+ 指标曲线dsh-session-list-health.timer;指标 dsh_session_list_{ok,latency_ms,rows,registered,missing,slow}timer active;曲线能看见「列表越来越慢」
有原则的自愈同一脚本 --heal:连续 2 轮 hard 失败且静态体检干净才重启该户服务;脏目录类拒绝重启(重启无用)、只是慢绝不自愈(会打断在跑会话)SESSION_LIST_HEALTH_HEALED user=<u>
当前状态(2026-09-13 18:0x):7 户探针全绿、静态体检 fatal=0 warn=0、不变量总闸 DSH_INVARIANTS=PASS ok=7 fail=0。也就是说:今天数据是干净的,用户看到的那次空列表是一次「已经过去」的失败——兜底能保证它下次发生时我们先知道,且不再需要用户来报。

可提交的 issue #8 正文(英文)

下面是可直接粘贴的正文;行号与版本都已核对(rc.1 / rc.2 逐字节相同)。

Title: Session listing is fail-closed: one bad session directory empties the
       entire `session/list` result (and the failed encoding check is cached
       for the process lifetime)

Summary
-------
`session/list` — the Web sidebar's only data source — is served by
`JsonlSessionPersistence#listArtifacts()`, which walks every project directory
and every session directory under the sessions root and decompresses each log's
first frame to read its header. The walk is fail-closed: any ONE of the
following aborts the WHOLE listing, so the client renders the workspace with
zero sessions (user report: "my history is gone") until a later attempt
happens to succeed:

  * a flat `.jsonl`/`.jsonl.zstd` file directly under a project directory
    (`listSessionDirs()` throws legacyLayout);
  * an opposite-encoding artifact, e.g. `session.v3.jsonl` beside the canonical
    `session.v3.jsonl.zstd` (`resolveGenerationInDirectory()` throws
    encodingMismatch);
  * a filename generation disagreeing with the header `version`;
  * a header whose `id`/`cwd` do not name the path it sits at (a missing `cwd`
    reads as `_no-cwd` and is equally fatal);
  * a retired policy field in the header (`sandboxMode`/`approvalPolicy`);
  * the same header id claimed by two project directories
    (`duplicate JSONL session id ...`).

The same function already treats an unparsable first line as a skippable row
(`if (header === void 0) continue`), and the API layer already degrades
projection columns with "serving the row without it" — so failing the whole
listing here is inconsistent with the module's own behaviour.

Aggravating factors
-------------------
1. `ensureRootEncoding()` uses `this.rootEncodingCheck ??= this.checkRootEncoding()`.
   A REJECTED promise is cached too, so one transient or one-shot encoding
   mismatch keeps every subsequent listing of that process failing until the
   service is restarted.

2. `session/list` takes no cursor and no limit, so it is O(all sessions on
   disk) on every call. Measured on a real deployment: 2957 rows / 1.28 MB /
   9.7–11.9 s; the same instance measured 1.9 s a few hours later with the same
   data, i.e. load-dependent. 2824 of those sessions are `origin=subagent`
   children the sidebar never renders, and they grow ~70/day. Note the ACP
   surface already has a keyset cursor for `session/list`
   (`decodeSessionListCursor` / `isAfterSessionListCursor`), and
   `dsh-session-query-sqlite` already maintains a
   `persisted_sessions(id, revision, generation)` derived index that the
   listing path does not use.

Requested behaviour
-------------------
  * Fail-soft per session directory: skip it and log a warning naming the
    path, consistent with the existing skips in the same function and with
    "serving the row without it" in the projection columns.
  * Do not cache a failed root-encoding check.
  * Consider `limit`/`cursor` for the Web `session/list` (reusing the ACP
    keyset cursor) and/or serving the listing from `persisted_sessions`.

Reproduction
------------
A dependency-free oracle modelling the shipped control flow under BUGGY and
PATCHED covers all seven poison kinds, the sticky encoding check, and the cost
shape; a three-hunk minimal patch (verified to apply to and parse against the
published `lib/index.js` of 0.1.5-rc.1 and 0.1.5-rc.2, which are byte-identical
in this file) accompanies it. Related: the earlier report that the same RPC can
hang without ever returning a byte on a unary path.

Versions: reproduced against @deepseek-ai/dsh 0.1.5-rc.1 (latest) and checked
against 0.1.5-rc.2 (next) — no change in the two packages involved.