DeepSeek Harness 本地 Spill 存储的一次性启动清理:`dsh-spill-local` 的 `cleanupPeriodDays` 机制解析
DeepSeek Harness 本地 Spill 存储的一次性启动清理:dsh-spill-local 的 cleanupPeriodDays 机制解析
导读
本文基于 DeepSeek Harness 仓库中的架构决策记录 2026-07-17-local-spill-startup-cleanup.zh.md,深入讲解 @deepseek-ai/dsh-spill-local 本地 spill 后端的一次性启动清理机制:它如何在每次进程激活时以“尽力而为”的方式回收过期 spill 文件、如何通过 cleanupPeriodDays 配置旋钮控制保留期限,以及如何在扫描安全性与并发写入之间取得平衡。读完本文,你将掌握该清理机制的设计动机、配置方式、扫描策略、安全约束、替代方案取舍,以及仓库中可验证的实现与测试证据。
背景:为什么 spill 文件需要清理
在 DeepSeek Harness 中,超大工具输出(如抓取的页面正文或冗长的工具响应)不会完整塞进下一次模型请求,而是由 spill 存储层落盘保存,模型通过文件读取工具按 locator 稍后检索。这套机制的定义见 2026-07-08-tool-output-spill-files.zh.md,存储 seam 划分如下:
| 包 | 角色 |
|---|---|
@deepseek-ai/dsh-spill |
接口:ctx.spillStore、词汇类型,不含存储实现 |
@deepseek-ai/dsh-spill-local |
本地后端:在宿主文件系统中提供私有、会话作用域的文件存储 |
@deepseek-ai/dsh-spill-policy |
工具结果策略插件:包装最终文本结果,以保留预览和 spill 定位符替换超大结果 |
问题在于:本地 spill 后端从不删除它写下的完整工具结果。每个超限结果都会新增一个文件,配置的根目录因此无限增长;而每进程默认的 dsh-spill-* 根目录(mkdtemp 在 OS 临时目录下创建)也会跨多次运行不断累积。立即删除是错误的——已持久化、已恢复和已 fork 的会话仍可能引用某个 locator。这要求 spill 策略具备有界的本地存储生命周期,这正是本决策要解决的问题。
核心决策:激活后的一次性启动清理
决策内容:dsh-spill-local 在激活后运行一次尽力而为的清理扫描。关键特征如下:
- 不延迟服务可用性:清理由插件 fiber 拥有,是一个
ctx.effect生成器,激活时启动扫描,但不 await 它;生成器 yield 一个异步 disposer,该 disposer 在 dispose 期间 await 同一个 promise。因此服务立即可用,而 fiber 卸载时没有扫描 I/O 会存活到 fiber 之后。 - 无周期性定时器:不是定时任务,也不引入独立进程。
- 默认保留期 30 天:
cleanupPeriodDays默认30,0表示禁用清理;Schemastery 在加载期拒绝负数或小数。
配置参数:cleanupPeriodDays 与 root
在 spill-local/src/index.ts 中,LocalSpillStore 的静态 Config 定义如下:
static Config: z<Config> = z.object({
root: z.string(),
cleanupPeriodDays: z.number().step(1).min(0).default(30),
})
root(可选):spill 文件根目录。省略时使用惰性创建的私有(0700)每进程目录(位于 OS 临时目录下),这是本地部署的安全默认;设置它可将 spill 文件收敛到已知位置。相对路径在构造时经resolve转为绝对路径(测试resolves a relative configured root to absolute覆盖)。cleanupPeriodDays(可选,默认30):spill 文件可被启动清理回收的年龄(天)。0完全禁用清理。z.number().step(1).min(0)保证加载期拒绝负数与非整数(测试rejects a negative or fractional cleanupPeriodDays at load与defaults cleanupPeriodDays to 30覆盖)。
截止时间换算在 runCleanup 中完成:cutoffMs = Date.now() - cleanupPeriodDays * MS_PER_DAY。cleanupPeriodDays: 0 时,ctx.effect 不启动扫描,直接 yield 一个 no-op disposer(this.cleanup 保持 undefined)。
扫描策略:扫描什么、删除什么
清理扫描由 cleanup.ts 中的无 ctx 依赖函数实现(便于单元测试),核心入口是 sweepSpillRoots 与 discoverDefaultRoots:
扫描范围:配置根 + 发现的默认根
扫描遍历两类根目录(见 gatherSweepRoots):
- 配置的/活动根目录(
activeRoot):当前进程正在写入的根,扫描后永不删除根本身; - 发现的先前默认根:在 OS 临时目录下匹配
dsh-spill-<6 字符>精确形态(mkdtemp追加的 6 字符后缀)的目录。匹配使用DEFAULT_ROOT_RE = /^dsh-spill-[A-Za-z0-9]{6}$/的精确形态,而非裸前缀——因此无关的dsh-spill-test-*fixture 或其他工具形态不同的dsh-spill-…目录绝不会被误当作后端根。
删除规则:按文件过期,而非按目录
- 在每个根下,只下钻到
session-<12 位十六进制>精确形态的会话目录(SESSION_DIR_RE),该目录名是sessionDir()从sha256(sessionId)前 12 位派生的稳定名字(见 store.ts); - 只删除
mtime严格早于now − cleanupPeriodDays的常规文件——位于年龄边界的文件被保留(测试keeps a file exactly at the boundary (only strictly-older expires)精确覆盖); - 修剪所有空会话目录(扫描后无条目的目录被
rmdir),但只删除发现的先前默认根本身(pruneWhenEmpty: true),活动的配置根永不删除; - 如果修剪与写入竞争,写入操作(
saveTextFile中的mkdir(dir, { recursive: true, mode: 0o700 })循环)会重新创建会话目录; - 符号链接绝不跟随或删除:所有条目一律
lstat,session-*符号链接不会下钻(避免删除外部目标中的文件),会话目录内部的符号链接与非常规条目(socket、fifo、嵌套目录)一律跳过; - 无关条目(非
session-目录、散落文件)被跳过,并阻断根目录的修剪(rootEmptiable = false)。
容错:扫描绝不抛出
每一次文件系统失败都被捕获并通过 ctx.logger.warn 记录;警告接收方自身抛出的异常也会被兜底(warnSafely)。因此扫描绝不会拒绝(reject),无法让激活失败,也无法影响并发的 spill 写入。unlinkIdempotent 将并发进程抢先删除同一文件(ENOENT)视为成功——目标(文件消失)已然达成。
安全模型:不受信任的本地 OS 用户
基于路径的删除仅限不受信任的本地 OS 用户无法在扫描期间替换的目录。在 POSIX 上(isTrustedDirectory / hasProtectedAncestors):
- 每个根目录和会话目录都必须由当前用户拥有,且组用户和其他用户不可写(
mode & 0o022 === 0); - 根目录的祖先路径也必须不可写,或由
/tmp这类 sticky 目录保护(sticky writable ancestor 是安全的,因为子目录归当前用户所有——这恰好容纳了/tmp下常规的每进程根); - 发现过程拒绝符号链接;配置的符号链接可以解析到可信目标并参与身份去重;
- 不安全路径会被跳过并记录警告。
根目录别名按设备/inode 身份去重(Windows 上退化为小写规范路径),配置目录的身份会覆盖发现的匹配项并标记为活动且不可删除(测试 de-dups when the active root is itself a discovered default、de-dups a configured symlink alias by filesystem identity 覆盖)。与后端的私有本地存储模型一致,同一用户账号仍是信任边界——spill 文件以 0600 独占写入、根目录以 0700 私有,其他本地用户不可读也不可替换。
实现分层与调用链
无 ctx 依赖的扫描机制位于 spill-local/src/cleanup.ts,与存储逻辑解耦:
| 文件 | 职责 |
|---|---|
cleanup.ts |
sweepSpillRoots、discoverDefaultRoots、gatherSweepRoots:扫描、过期判定、身份去重、路径安全检查,全程无 ctx 依赖,可独立单元测试 |
store.ts |
根目录命名(DEFAULT_ROOT_PREFIX = 'dsh-spill-')、privateRoot()(惰性 mkdtempSync)、sessionDir()(sha256 前 12 位)、saveTextFile()(0700 会话目录 + 0600 独占写入)、encodeSegment()(注入式路径安全段编码) |
index.ts |
LocalSpillStore 服务:配置解析、截止时间换算、fiber 拥有的启动/等待 |
调用链:激活 → ctx.effect 生成器 → runCleanup(warn) → gatherRoots(warn)(= gatherSweepRoots(this.root, warn, tmpdir()))→ sweepSpillRoots({ roots, cutoffMs, warn });dispose → 异步 disposer await this.cleanup,保证 fiber 卸载前扫描 I/O 完全静止。
考虑过的替代方案(及否决理由)
| 替代方案 | 否决理由 |
|---|---|
| 周期性定时器 | 引入定时器生命周期、重叠控制与又一个间隔旋钮;长期运行的进程可能保留文件直到重启 |
| 会话 dispose 时删除 spill | 持久会话、恢复和 fork 都会保留 locator,删除会破坏已持久化内容的可检索性 |
| 递归删除旧的会话目录 | 并发进程可能在年龄检查之后创建新的 spill;按文件过期可保留新写入 |
| 将清理绑定到会话持久化删除 | 持久化 seam 没有共同的删除生命周期,而本地后端还独立拥有临时根目录 |
后果与取舍
清理让后端付出了一次启动扫描和一个配置旋钮的代价,换来无需定时器、守护进程或会话生命周期耦合的有界本地存储生命周期。要点:
- 并发进程可能重复启动扫描 I/O;严格的过滤与幂等的文件删除保证这一点是安全的;
- 长期运行的进程在重启前不会再次清理,这种保留是刻意的——旧的模型可见 locator 只有在超过截止时间后才会失效;
- seam 本身(
@deepseek-ai/dsh-spill)不定义任何保留策略——这是本地后端的关切,为其他后端(如远程/对象存储)保留自由。
验证:测试覆盖
dsh-spill-local 的单元测试(spill-local/tests/spill-local.spec.ts)覆盖了精确年龄边界、cleanupPeriodDays: 0 的禁用、空会话目录与发现根目录的修剪、符号链接/无关条目的跳过、配置根加发现根的覆盖、经配置符号链接验证的文件系统身份去重、不安全 POSIX 根目录/会话目录拒绝、加载期配置校验、文件系统与警告接收方故障兜底,以及静止契约。另一个测试(spill-local/tests/loader-composition.spec.ts)会通过真实 Loader 和 cordis.yml 启动插件,并在 dispose 后观察按配置执行的过期与目录修剪。
小结
dsh-spill-local 的一次性启动清理是一个“轻量而有界”的存储生命周期方案:一次启动扫描 + 一个 cleanupPeriodDays 旋钮,换来无需定时器、守护进程或会话生命周期耦合的回收机制。它以精确的形态匹配、严格的安全路径检查和幂等的文件删除保证了并发安全,同时把保留语义完全留给本地后端自行定义——这正是 DeepSeek Harness “Everything is a Plugin” 架构中存储 seam 关注点分离的一个缩影。