DSH / Atlas
2026-07-17implementedarchitecturearchived 2026-09-04

One-shot startup cleanup for local spill files

本地 spill 文件的一次性启动清理

The local spill backend never deleted the full tool results it wrote. Every oversized result added another file, so configured roots grew without bound and default per-process `dsh-spill-*` roots accumulated across runs. Immediate deletion is wrong because persisted, resumed, and forked sessions may still reference a locator. The [tool output spill policy](./2026-07-08-tool-output-spill-files.md) needs a bounded loca

English

Problem

The local spill backend never deleted the full tool results it wrote. Every oversized result added another file, so configured roots grew without bound and default per-process dsh-spill-* roots accumulated across runs. Immediate deletion is wrong because persisted, resumed, and forked sessions may still reference a locator. The tool output spill policy needs a bounded local-storage lifetime.

Decision

dsh-spill-local runs one best-effort cleanup sweep after activation. It does not delay service availability, is owned by the plugin fiber (a single ctx.effect whose generator launches the sweep and yields an async disposer that awaits it), and is awaited during disposal so no sweep I/O outlives the fiber. There is no recurring timer and no separate process.

A cleanupPeriodDays config defaults to 30; 0 disables cleanup. Schemastery rejects a negative or fractional value at load. The sweep scans the configured/active root plus any prior default dsh-spill-* temp roots discovered under the OS temp dir and deletes regular files whose mtime is strictly older than now − cleanupPeriodDays. It prunes every empty session directory but removes the root itself only for a discovered prior-default root; writes recreate a session directory if pruning races them. Root aliases are de-duplicated by device/inode identity, with the configured identity overriding a discovered match as active and non-prunable. It uses lstat, so a symlink is never followed or deleted; unrelated entries (non-session- directories, special files) are skipped. Every filesystem failure is caught and logged through ctx.logger.warn, and a warning-sink exception is also contained — the sweep never throws, so it cannot reject activation or a concurrent spill write.

Path-based deletion is restricted to directories an untrusted local OS user cannot replace during the scan. On POSIX, every root and session directory must be owned by the current user and not writable by group or others; the root's ancestor path must also be non-writable or protected by a sticky directory such as /tmp. Discovery rejects symlinks, while a configured symlink may resolve to a trusted target and participates in identity de-duplication. An unsafe path is skipped with a warning. The same-user account remains the trust boundary, consistent with the backend's private local-storage model.

The ctx-free sweep mechanics live in packages/spill/spill-local/src/cleanup.ts (sweepSpillRoots, discoverDefaultRoots), unit-testable without a ctx; store.ts owns root naming, path derivation, and writes, while the service in src/index.ts owns the config, cutoff, and fiber-owned launch/await.

Alternatives considered

Run a periodic timer. Rejected because it adds timer lifecycle, overlap control, and another interval knob. A long-lived process may retain files until restart.

Delete spills on session disposal. Rejected because durable sessions, resumes, and forks retain locators.

Delete old session directories recursively. Rejected because a concurrent process may create a fresh spill after the age check. Per-file expiry preserves fresh writes.

Tie cleanup to session-persistence deletion. Rejected because the persistence seam has no common deletion lifecycle, while the local backend also owns independent temporary roots.

Consequences

Cleanup cost the backend a startup sweep and a config knob, and bought a bounded local-storage lifetime without a timer, a daemon, or a session-lifecycle coupling. Concurrent processes may duplicate startup I/O; strict filtering and idempotent file deletion keep this safe. A long-lived process is not cleaned again until restart, and retention deliberately makes old model-visible locators stale only once they age past the cutoff. The seam itself still defines no retention policy — this is a local-backend concern.

Testing

dsh-spill-local unit tests cover the exact age boundary, cleanupPeriodDays: 0 disabling, empty-session and discovered-root pruning, symlink/unrelated-entry skipping, configured-plus-discovered-root coverage, filesystem-identity de-duplication through a configured symlink, unsafe POSIX root/session rejection, load-time config validation, filesystem- and warning-sink-failure containment, and the quiescence contract. A separate test boots the plugin through the real Loader and a cordis.yml, then observes configured expiry and directory pruning after disposal.

中文

问题

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

决策

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 后观察按配置执行的过期与目录修剪。