# Agent Note: 本地 spill 文件的一次性启动清理

Status: implemented

[English](2026-07-17-local-spill-startup-cleanup.md) | 中文

## 问题

本地 spill 后端从不删除它写下的完整工具结果。每个超限结果都会新增一个文件，因此配置的根目录会无限增长，而每进程默认的 `dsh-spill-*` 根目录也会跨多次运行不断累积。立即删除是错误的，因为已持久化、已恢复和已 fork 的会话仍可能引用某个 locator。[工具输出 spill 策略](./2026-07-08-tool-output-spill-files.zh.md)需要一个有界的本地存储生命周期。

## 决策

`dsh-spill-local` 在激活后运行一次尽力而为的清理扫描。它不延迟服务可用性，由插件 fiber 拥有（一个 `ctx.effect`，其生成器启动该扫描并让出一个等待它的异步 disposer），并在 dispose 期间被等待，因此没有扫描 I/O 会存活到 fiber 之后。既没有周期性定时器，也没有独立进程。

`cleanupPeriodDays` 配置默认为 `30`；`0` 会禁用清理。Schemastery 会在加载时拒绝负数或小数。扫描会遍历配置的/活动的根目录，以及在 OS 临时目录下发现的任何先前默认 `dsh-spill-*` 临时根目录，并删除 `mtime` 严格早于 `now − cleanupPeriodDays` 的常规文件。它会修剪所有空会话目录，但只删除发现的先前默认根目录本身；如果修剪与写入发生竞争，写入操作会重新创建会话目录。根目录别名按设备/inode 身份去重，配置目录的身份会覆盖发现的匹配项，并标记为活动且不可删除。扫描使用 `lstat`，因此符号链接绝不会被跟随或删除；无关条目（非 `session-` 目录、特殊文件）会被跳过。每一次文件系统失败都会被捕获并通过 `ctx.logger.warn` 记录，警告接收方抛出的异常也会被兜底——扫描绝不抛出，因此它无法让激活失败，也无法影响并发的 spill 写入。

基于路径的删除仅限于不受信任的本地 OS 用户无法在扫描期间替换的目录。在 POSIX 上，每个根目录和会话目录都必须由当前用户拥有，且组用户和其他用户不可写；根目录的祖先路径也必须不可写，或由 `/tmp` 这类 sticky 目录保护。发现过程拒绝符号链接，而配置的符号链接可以解析到可信目标并参与身份去重。不安全路径会被跳过并记录警告。与后端的私有本地存储模型一致，同一用户账号仍是信任边界。

无 ctx 依赖的扫描机制位于 `packages/spill/spill-local/src/cleanup.ts`（`sweepSpillRoots`、`discoverDefaultRoots`），无需 `ctx` 即可做单元测试；`store.ts` 负责根目录命名、路径推导与写入，而 `src/index.ts` 中的服务负责配置、截止时间以及 fiber 拥有的启动/等待。

## 考虑过的替代方案

**运行周期性定时器。** 已否决，因为它引入了定时器生命周期、重叠控制以及又一个间隔旋钮。长期运行的进程可能会保留文件直到重启。

**在会话 dispose 时删除 spill。** 已否决，因为持久会话、恢复和 fork 都会保留 locator。

**递归删除旧的会话目录。** 已否决，因为并发进程可能在年龄检查之后创建一个新的 spill。按文件过期可保留新写入。

**将清理绑定到会话持久化删除。** 已否决，因为持久化 seam 没有共同的删除生命周期，而本地后端还独立拥有临时根目录。

## 后果

清理让后端付出了一次启动扫描和一个配置旋钮的代价，换来了无需定时器、守护进程或会话生命周期耦合的有界本地存储生命周期。并发进程可能重复启动 I/O；严格的过滤与幂等的文件删除保证了这一点的安全。长期运行的进程在重启前不会再次被清理，而这种保留是刻意的——旧的模型可见 locator 只有在超过截止时间后才会失效。seam 本身仍不定义任何保留策略——这是本地后端的关切。

## 验证

`dsh-spill-local` 单元测试覆盖了精确年龄边界、`cleanupPeriodDays: 0` 的禁用、空会话目录与发现根目录的修剪、符号链接/无关条目的跳过、配置根加发现根的覆盖、经配置符号链接验证的文件系统身份去重、不安全 POSIX 根目录/会话目录拒绝、加载期配置校验、文件系统与警告接收方故障兜底，以及静止契约。另一个测试会通过真实 Loader 和 cordis.yml 启动插件，并在 dispose 后观察按配置执行的过期与目录修剪。
