DeepSeek Harness 的工作区概念:workspace 划出来的那条线在哪
先说清楚这篇要回答的那个具体问题:你在客户端里把一个目录登记成工作区,又把某个会话挂了进去,结果它在这个工作区的会话列表里不出现。谁说了算?记录里明明写着它的 id。
这个问题的答案不在文档的概述段落里,它在 packages/workspace/workspace/src/entity.ts 的一行 filter 里。下面沿着代码路径走一遍。
需要先立一个前提:deepseek-harness 这个仓库的 README 有一节 Developer preview,原文写明项目处于开发者预览阶段、正在快速迭代,并用大写强调「会有破坏兼容性的变更」。这篇文章里提到的方法名、字段名、错误信息,都是某一时刻的快照,随时可能改。
第一条线:workspace 跟模型没有半点关系
docs/subsystems/workspace.md 开头就把定位钉死了:这个子系统只有一个包(packages/workspace/workspace,服务名 ctx.workspaceRegistry),是宿主侧的可选能力,不属于 agent loop 主干,并且对模型不可见——文档原文列了三个「没有」:没有工具、没有提示词文本、没有会话事件。包自己的 README 里还有一节「模型体验」,写得更直白:模型看到的内容是「没有」,每个请求的直接 token 为零,因为这个包绝不触及请求前缀,所以不会让提供方的缓存复用失效。
这条线很容易被读者自己的经验带偏。很多人一看到 workspace,会立刻联想到「给模型划定的可操作范围」,也就是某种沙箱边界。在这个仓库里不是。它就是一条给人看的持久记录:一个建立在规范路径上的稳定 id、一个显示标题、一条属于它的会话的有序账本。
同一份文档还专门澄清了一处同名陷阱:packages/context/agent-instructions 不是这个注册表的消费方,尽管它的语境里也天天说「工作区」。那个包做的事是在 agent 自己的 cwd 下发现 AGENTS.md 风格的指令文件,跟 ctx.workspaceRegistry 没有调用关系。我们在该包 src 目录下检索 workspaceRegistry 这个标识符,一处都没有。
顺带一提真实的消费面有多窄:整个仓库里 package.json 提到 @deepseek-ai/dsh-workspace 的一共三处,除去它自己,只有 packages/host/apiproxy 和 packages/bundle/web-app。文档里点名的产品消费方就是前者。
第二条线:身份是 uuid,唯一性是规范路径
src/types.ts 里 WorkspaceId 的注释把理由写在了明处:它是生成的 uuid,绝不是路径——因为路径规范化会重写路径,而一个引用锚点必须保持稳定。这是文档自述的理由,不是我们的推测。
那唯一性靠什么?靠 src/paths.ts。这个文件整个只有 22 行,导出的 realpathNormalize 函数体就一句 await realpath(path)。注释称它是这个包唯一的一套唯一性规范:工作区路径以规范化形式存储,唯一性就是规范路径的字符串相等,attach 时对会话 cwd 的检查也走同一套。尾部斜杠、..、符号链接全部由 fs.realpath 解析。
于是有一条直接的后果,文档写明了:一个指向已被拥有目录的符号链接,会和那个已有工作区撞车。你以为登记的是两个目录,注册表认为是同一个。create(path, title?) 对同一规范路径重复调用,返回既有实体,而且不会改它的标题。
create 的拒绝路径也很具体:路径不存在时原样传出原始的 ENOENT;src/index.ts 里紧接着一句 stat(canonical) 判断,不是目录就抛 path is not a directory。
第三条线:成员资格是两个条件,而不是一个
回到开头那个问题。docs/subsystems/workspace.md 的原文是:所有权的真源是记录里有序的 sessionIds,绝不从会话 cwd 派生——但成员资格要求两者同时成立,账本上有它的 id,且这个会话 header 的规范 cwd 等于工作区路径。
落到代码上,就是 src/entity.ts 里 sessionIds 这个 getter:
get sessionIds(): readonly SessionId[] {
return this.record.sessionIds.filter(id => this.host.sessionPath(id) === this.record.path)
}
每次读都过滤一遍。所以「记录里写着 id,列表里却没有」这件事在这个设计里完全正常:持久层的账本是候选账本,缺失 header、cwd 值无效、规范 cwd 对不上的候选项,一律不返回。
这些被过滤掉的候选项不会立刻从磁盘上消失。entity.ts 里所有写操作都走同一个私有的 mutate,它在 table.update 回调里做两件事:按同样的规则把 sessionIds 过滤一遍,然后盖上新的 updatedAt。也就是说,下一次任何被接受的变更,顺手把这些候选项持久修剪掉。如果 fn 返回的记录跟当前完全相同、过滤也没减少条目,它会抛一个包内私有的 unchangedSentinel 把这次写中止掉——空操作既不重写介质也不发出变更事件。
怎么确认自己撞的就是这个问题:src/index.ts 里有个 reportFilteredCandidates,启动时对每个实体逐条比对,凡是被过滤掉的都会打一条 ctx.logger.warn,格式是 workspace '<id>' filtered session '<id>' from membership: <原因>。原因字符串就那么几种,都能在 indexHeader 与 reportFilteredCandidates 里逐条对上号:
| 日志里的原因 | 源码里的判定 |
|---|---|
session header is missing | 头部索引里根本没有这个会话 |
header has no cwd | header 的 cwd 是 undefined |
cwd '<x>' does not resolve / cwd '<x>' is not a directory | realpathNormalize 抛错,或规范化后 stat 不是目录 |
canonical cwd '<x>' differs from workspace path '<y>' | 两个规范路径不相等 |
所以先去翻启动日志里这条 warn,而不是去猜是不是没保存。什么情况说明不是这个原因:如果日志里压根没有这个会话的 warn,且 list() 也正常返回了工作区,那问题不在成员资格过滤这一层——sessionIds 只在候选被判定为不合格时才不出现,它不会因为「最近没活动」而消失(types.ts 的注释明写:显式重排走 insertSessionBefore,活动从不重排顺序)。
attach 的四条拒绝路径
attachSession 的校验写在 entity.ts 里,四种情况各抛各的错,错误信息里都带上了会话 id 和工作区路径:
- header 里没有 cwd:
its stored header carries no cwd to validate against - cwd 无法解析:
does not resolve, so it cannot be validated - cwd 解析出来不是目录:
is not a directory - 解析结果跟工作区路径不等:
its cwd resolves to '<别处>'
四条都是「拒绝且不写入」。还有一个容易被忽略的分支:如果这个 id 已经在当前快照的账本里,上面这一整套校验会被跳过。代码注释给了理由(文档自述):cwd 这个事实在它第一次 attach 时已经查过,而两个输入——已存储的 header cwd 和工作区路径——都是不可变的。
会话的 cwd 不是这个注册表赋予的。文档写明流程是:API 网关从所选工作区的 path 解析新会话的 cwd(回退到显式或默认 cwd),先创建会话让 cwd 落进它不可变的 SessionHeader,再调用 attachSession 重新校验一遍。在 packages/host/apiproxy/src/api-proxy.ts 里能看到对应的 ensureWorkspace:先 resolveByPath,没有才 create,整段挂在一条 workspaceCreationChain 上串行。
删除只删注册,不删任何东西
delete(id) 的语义值得单独记一笔,因为它跟很多人对「删除工作区」的直觉相反:它只移除注册记录、顺序条目和会话账本;目录、用户文件、实时会话、已持久化的日志一概不动,这些会话因此变成 Ungrouped。未知 id 返回 false。
包 README 里补了一条更容易踩的:删除后重新注册同一路径,会生成新的 Workspace id,而且不会自动重新接纳那些保留下来的会话。也就是说「删掉再建回来」不是一个还原操作。
README 的「已知限制与暂缓事项」里还明说了:会话删除与破坏性的文件夹移除是彼此独立、尚未提供的功能,删除工作区注册记录绝不能替代二者。
两次写入之间那个标记
create 和 delete 都要写两处:记录表和顺序。中间断电会分叉。这个包的处理办法在 src/spec.ts 里能直接读到——workspaceDomainState 里除了 initialized、workspaceIds、archivedSessionIds,还有一个可选的 pendingMutation,它是一个按 operation 判别的联合,只有 create 和 delete 两种取值,各带一个 workspaceId。
标记先于两次写入落盘。启动时 recoverPendingMutation 只做一件很克制的事:删掉被标记的那一行表记录。这一个动作同时完成了两种恢复——补完被中断的 delete,回滚被中断的 create。文档给了选择这个方向的理由(文档自述):注册是可以重建的,所以回滚是安全方向。
没有标记的顺序/表不一致则一律当作损坏,validateStoredState 会大声抛错,错误信息一句比一句具体:顺序里重复出现某个 id、顺序引用了不存在的记录、某个记录不在顺序里、同一路径被两条记录声明、同一会话被两个工作区记账。这些不是警告,是直接抛。
同一个 src/spec.ts 里还有两个具体数值值得记住:domain 名字是 workspace,version 是 2,表只有一张,叫 workspaces;archivedSessionIds 用了 .default([])——注释写明,这样在这个字段出现之前写入的记录仍然能原样解析。归档是叠在工作区记账之上的注册表级全局集合,被归档的会话保留它在 sessionIds 里的席位,所以它从来不参与「一个会话只属于一个工作区」这条不变量。
包里还带了一个 cordis 伴生插件 src/invariant.ts(name 是 workspace-invariant),监听 domain/changed,只管 domain === 'workspace' 且 table === 'workspaces' 的变更:表里删了一行而注册表缓存还在发布这个实体,就判定「某条写路径绕过了 ctx.workspaceRegistry」并 fail。绕过服务直接写表这件事,它是会喊出来的。
一处文档与源码口径不一致
这个要单独指出来,因为它会直接影响你写调用代码时的预期。
docs/subsystems/workspace.md 第 120 行(中文版同一行)在讲 create 时写道:新记录不得与既有显示标题重复,并给出了错误类名 WorkspaceNameConflictError。而同一个页面下方由脚本生成的 Cordis 签名块里,create 的 JSDoc 写的是 Different canonical paths may share a display title(不同规范路径可以共用显示标题);包自己的 README 也是后一种说法。
我们在 packages/workspace/workspace/src 下没有找到这个错误类,也没有找到任何标题去重的检查;createCanonical 里标题的处理只有一句 title ?? basename(canonical)。全仓检索 WorkspaceNameConflictError,定义在 packages/host/apiproxy/src/api-proxy.ts 第 1059 行,唯一的抛出点在同文件的 workspace.rename 那一行处理里,检查的是「改名后是否与其它工作区标题相同」。两处说法不一致;以我们实读的源码为准。说完这个差异就停在这里。
另外 src/index.ts 里 create 方法上方挂着一条源码里写明的 TODO,大意是 title 参数在网关的按名创建分支被删掉后失去了最后一个生产调用方,计划连同 @param 一起去掉。这也是源码自述,不是我们的推断。对照 api-proxy.ts 里的 ensureWorkspace,那里调用的确实是 ctx.workspaceRegistry.create(path),没有传第二个参数。
什么时候你会真的碰到这条线
如果你只是把 dsh 当命令行工具用,这个包大概率跟你无关——它是可选的宿主侧能力,启动依赖 storageDomain 与 sessionPersistence(WorkspaceRegistry 的 static inject 里就这两项),文档写明持久化依赖不可用时插件保持 pending,而不是把「读不到」误当成空历史。
真正会咬到你的场景有三类:一是你在做自己的宿主端或 GUI,需要理解「会话为什么不在这个分组里」;二是你的目录里有符号链接,或者会被移动、被重命名——status() 是未缓存的实时目录检查,返回 'ok' | 'missing-dir',而且目录缺失绝不改动记录,注释给的理由是目录可能只是被临时移走了;三是你打算绕过 ctx.workspaceRegistry 直接动那张 workspaces 表,那么上面那个伴生不变量会在第一时间告诉你不该这么干。
以上都是仓库快照里的源码与文档口径。这个项目仍在开发者预览阶段,方法名、字段名、错误信息随时可能变动,动手前请以仓库最新内容为准。
本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的
架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。
本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目,
因此不涉及界面外观、操作手感与运行速度的任何描述。
该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更,
文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。