Explorar o código

feat(spill-local): one-shot startup cleanup for local spill files

The local spill backend never reclaimed its files, so configured roots
grew without bound and default per-process dsh-spill-* temp roots piled
up across runs. Immediate deletion is unsafe because persisted, resumed,
and forked sessions may still reference an older locator.

Add a fiber-owned, best-effort sweep that runs once after activation
(never delaying availability, awaited on disposal): it deletes regular
files older than cleanupPeriodDays (default 30; 0 disables) across the
configured root and prior default temp roots, prunes emptied dirs, and
skips symlinks/unknown entries. Every filesystem failure is contained
and logged, so the sweep cannot fail activation or a concurrent write.
Dudu-0223 hai 1 mes
pai
achega
2f430f2fbd

+ 2 - 1
.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md

@@ -160,7 +160,8 @@ Those cases can consume `ctx.spillStore` directly in later work. They are not pa
 - Tool-owned spill for subagent rollouts (`await run.result`, read in-process child session before `run.dispose()`, save JSONL).
 - Per-tool opt-out or per-tool policy declarations if the built-in `read` skip is insufficient.
 - Remote or database storage backends for ACP or remote environments where a local path is not meaningful.
-- Cleanup and retention policy for old spill files, likely tied to session cleanup.
+
+Cleanup shipped for the local backend as a one-shot startup sweep, not tied to session deletion — see the [startup-cleanup RFC](./2026-07-17-local-spill-startup-cleanup.md). The seam still defines no per-session cleanup policy; retention is a backend concern.
 
 ## Testing
 

+ 6 - 0
.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write
+2026-07-17-local-spill-startup-cleanup.md: ca4931776f89e641f127072f665e238ca2a1600d
+2026-07-17-local-spill-startup-cleanup.zh.md: b90923844ab71e1ce570e8adb66f81aed7bc3488

+ 35 - 0
.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.md

@@ -0,0 +1,35 @@
+# Agent Note: One-shot startup cleanup for local spill files
+
+Status: implemented
+
+English | [中文](2026-07-17-local-spill-startup-cleanup.zh.md)
+
+## 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](./2026-07-08-tool-output-spill-files.md) 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. An invalid value (negative or fractional) throws at load. The sweep scans the configured/active root plus any prior default `dsh-spill-*` temp roots discovered under the OS temp dir, deletes regular files whose `mtime` is strictly older than `now − cleanupPeriodDays`, and prunes directories left empty. 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` — the sweep never throws, so it cannot reject activation or a concurrent spill write. Discovery excludes symlinks and non-directories, returning only real `dsh-spill-*` directories the backend could have created.
+
+The ctx-free mechanics live in `packages/spill/spill-local/src/store.ts` (`sweepSpillRoots`, `discoverDefaultRoots`, `DEFAULT_ROOT_PREFIX`, `isErrno`), unit-testable without a `ctx`; the service in `src/index.ts` owns the config, the cutoff, and the 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 age boundary (strictly-older expires, boundary kept), `cleanupPeriodDays: 0` disabling, empty-directory pruning, symlink/unrelated-entry skipping, configured-plus-discovered-root coverage through the real `gatherRoots`/`discoverDefaultRoots` path, active-root de-duplication, load-time validation of a bad `cleanupPeriodDays`, filesystem-failure containment (logged, not thrown) both directly and through the service's `ctx.logger.warn` wiring, and the quiescence contract — activation is available while a barrier-held sweep is parked, and disposal only settles after the sweep finishes.

+ 35 - 0
.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.zh.md

@@ -0,0 +1,35 @@
+# 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.md)需要一个有界的本地存储生命周期。
+
+## 决策
+
+`dsh-spill-local` 在激活后运行一次尽力而为的清理扫描。它不延迟服务可用性,由插件 fiber 拥有(一个 `ctx.effect`,其生成器启动该扫描并让出一个等待它的异步 disposer),并在 dispose 期间被等待,因此没有扫描 I/O 会存活到 fiber 之后。既没有周期性定时器,也没有独立进程。
+
+`cleanupPeriodDays` 配置默认为 `30`;`0` 会禁用清理。无效值(负数或小数)在加载时抛出。扫描会遍历配置的/活动的根目录,以及在 OS 临时目录下发现的任何先前默认 `dsh-spill-*` 临时根目录,删除 `mtime` 严格早于 `now − cleanupPeriodDays` 的常规文件,并修剪清空后的目录。它使用 `lstat`,因此符号链接绝不会被跟随或删除;无关条目(非 `session-` 目录、特殊文件)会被跳过。每一次文件系统失败都会被捕获并通过 `ctx.logger.warn` 记录——扫描绝不抛出,因此它无法让激活失败,也无法影响并发的 spill 写入。发现过程排除符号链接与非目录,只返回后端可能创建过的真实 `dsh-spill-*` 目录。
+
+无 ctx 依赖的机制位于 `packages/spill/spill-local/src/store.ts`(`sweepSpillRoots`、`discoverDefaultRoots`、`DEFAULT_ROOT_PREFIX`、`isErrno`),无需 `ctx` 即可做单元测试;`src/index.ts` 中的服务负责配置、截止时间以及 fiber 拥有的启动/等待。
+
+## 考虑过的替代方案
+
+**运行周期性定时器。** 已否决,因为它引入了定时器生命周期、重叠控制以及又一个间隔旋钮。长期运行的进程可能会保留文件直到重启。
+
+**在会话 dispose 时删除 spill。** 已否决,因为持久会话、恢复和 fork 都会保留 locator。
+
+**递归删除旧的会话目录。** 已否决,因为并发进程可能在年龄检查之后创建一个新的 spill。按文件过期可保留新写入。
+
+**将清理绑定到会话持久化删除。** 已否决,因为持久化 seam 没有共同的删除生命周期,而本地后端还独立拥有临时根目录。
+
+## 后果
+
+清理让后端付出了一次启动扫描和一个配置旋钮的代价,换来了无需定时器、守护进程或会话生命周期耦合的有界本地存储生命周期。并发进程可能重复启动 I/O;严格的过滤与幂等的文件删除保证了这一点的安全。长期运行的进程在重启前不会再次被清理,而这种保留是刻意的——旧的模型可见 locator 只有在超过截止时间后才会失效。seam 本身仍不定义任何保留策略——这是本地后端的关切。
+
+## 验证
+
+`dsh-spill-local` 单元测试覆盖了年龄边界(严格更旧者过期,边界值保留)、`cleanupPeriodDays: 0` 的禁用、空目录修剪、符号链接/无关条目的跳过、通过真实 `gatherRoots`/`discoverDefaultRoots` 路径对配置根加发现根的覆盖、活动根去重、对错误 `cleanupPeriodDays` 的加载期校验、文件系统失败的兜底(记录而非抛出)——既直接测试,也经由服务的 `ctx.logger.warn` 接线测试——以及静止契约:在一个被屏障挂起的扫描停驻期间激活仍然可用,而 dispose 只有在扫描结束后才会完成。

+ 10 - 1
docs/config-catalog.md

@@ -2062,10 +2062,19 @@ export interface Config {
    * a local deployment. Set it to keep spill files under a known location.
    */
   root?: string
+  /**
+   * Age in days after which a spill file is eligible for the one-shot startup
+   * cleanup sweep. Defaults to `30`; `0` disables cleanup entirely. Files whose
+   * `mtime` is strictly older than the cutoff are deleted and emptied
+   * directories are pruned; fresh files, symlinks, and unrelated entries are
+   * left untouched. Retention is deliberate — a resumed or forked session may
+   * still reference an older locator until it ages out.
+   */
+  cleanupPeriodDays?: number
 }
 ```
 
-Source: [`packages/spill/spill-local/src/index.ts:22`](../packages/spill/spill-local/src/index.ts)
+Source: [`packages/spill/spill-local/src/index.ts:28`](../packages/spill/spill-local/src/index.ts)
 
 <a id="deepseek-aidsh-spill-policy"></a>
 

+ 9 - 2
packages/spill/spill-local/README.md

@@ -17,8 +17,15 @@ Files land at `<root>/session-<hash>/​<random>-<safeName>`:
 | Key | Default | Meaning |
 |---|---|---|
 | `root` | private 0700 temp dir | Root directory for spill files. Set to keep them under a known location. |
+| `cleanupPeriodDays` | `30` | Age in days after which a spill file is eligible for the one-shot startup cleanup sweep. `0` disables cleanup. |
 
-`saveText` rejects on a real storage failure (permissions, ENOSPC); the spill policy treats a rejection as best-effort and keeps the inline result. See the seam README for the vocabulary and the [tool output spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md) for the design.
+## Startup cleanup
+
+The backend never deletes a spill on the write path — a persisted, resumed, or forked session may still reference an older locator, so immediate deletion would break retrieval. Instead, one best-effort sweep runs **once after activation**: it does not delay service availability, is owned by the plugin fiber, and is awaited on disposal (no sweep I/O outlives the fiber). There is no recurring timer and no separate process, so a long-lived deployment is not swept again until its next restart.
+
+The sweep scans the configured `root` **and** any earlier default `dsh-spill-*` temp roots that prior default-root runs left under the OS temp dir. Within each, it deletes regular files whose `mtime` is strictly older than `now − cleanupPeriodDays` and prunes any directory left empty. It never follows or deletes a symlink, skips unrelated entries, and contains every filesystem failure (logged, never thrown) so it cannot fail activation or a concurrent spill write. Retention is deliberate: an old model-visible locator goes stale only once it ages past the cutoff.
+
+`saveText` rejects on a real storage failure (permissions, ENOSPC); the spill policy treats a rejection as best-effort and keeps the inline result. See the seam README for the vocabulary and the [tool output spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md) for the design, and the [startup-cleanup Agent Note](../../../.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.md) for the sweep.
 
 ## Model Experience
 
@@ -30,5 +37,5 @@ No direct invalidation; the named consumer owns any request-prefix changes.
 
 ## Known Limitations and Deferred Work
 
-- **Local spill files persist until external cleanup** — the backend has no session-lifecycle deletion or age-based retention policy, because persisted, resumed, and forked sessions may still reference a path.
+- **A long-lived deployment is not swept until restart** — the one-shot sweep runs once after activation, so files that age past `cleanupPeriodDays` mid-run are reclaimed only on the next start; there is no recurring timer.
 - **Locators require a co-located filesystem consumer** — a remote or virtual deployment needs another `SpillStore` backend whose locator and retrieval hint are meaningful there.

+ 99 - 4
packages/spill/spill-local/src/index.ts

@@ -3,20 +3,26 @@
  * `@deepseek-ai/dsh-spill` storage seam. Persists a tool's oversized text to a
  * private, session-scoped file (see `./store.ts` for the traversal-safe naming
  * and exclusive owner-only write) and returns a path locator plus local
- * read/grep retrieval guidance.
+ * read/grep retrieval guidance. After activation it runs one best-effort
+ * startup sweep that reclaims spill files older than `cleanupPeriodDays`.
  *
  * @module @deepseek-ai/dsh-spill-local
  */
 
 import { Context } from '@deepseek-ai/cordis'
 import { resolve } from 'node:path'
+import { tmpdir } from 'node:os'
 import z from '@deepseek-ai/schemastery'
 import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill'
 import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
-import { privateRoot, saveTextFile } from './store.ts'
+import { discoverDefaultRoots, privateRoot, saveTextFile, sweepSpillRoots } from './store.ts'
+import type { WarnFn } from './store.ts'
 
-export { encodeSegment, privateRoot, saveTextFile, sessionDir } from './store.ts'
-export type { SavedText, SaveTextOptions } from './store.ts'
+export { discoverDefaultRoots, encodeSegment, isErrno, privateRoot, saveTextFile, sessionDir, sweepSpillRoots, DEFAULT_ROOT_PREFIX } from './store.ts'
+export type { SavedText, SaveTextOptions, SweepOptions, WarnFn } from './store.ts'
+
+/** Milliseconds in one day — converts the `cleanupPeriodDays` config to the sweep cutoff. */
+const MS_PER_DAY = 24 * 60 * 60 * 1000
 
 /** Plugin config (all optional — `static Config` supplies the defaults). */
 export interface Config {
@@ -26,25 +32,114 @@ export interface Config {
    * a local deployment. Set it to keep spill files under a known location.
    */
   root?: string
+  /**
+   * Age in days after which a spill file is eligible for the one-shot startup
+   * cleanup sweep. Defaults to `30`; `0` disables cleanup entirely. Files whose
+   * `mtime` is strictly older than the cutoff are deleted and emptied
+   * directories are pruned; fresh files, symlinks, and unrelated entries are
+   * left untouched. Retention is deliberate — a resumed or forked session may
+   * still reference an older locator until it ages out.
+   */
+  cleanupPeriodDays?: number
 }
 
+/** The shape after schemastery applied the defaults. */
+type ResolvedConfig = Required<Omit<Config, 'root'>> & Pick<Config, 'root'>
+
 /**
  * Local-filesystem spill backend. Files land under `<root>/session-<hash>/…`
  * with unpredictable names, an exclusive owner-only (0600) write, and a private
  * (0700) root — a spilled tool result must not be readable by other local users
  * or redirectable via a planted symlink.
+ *
+ * After activation it launches ONE best-effort cleanup sweep (see
+ * {@link cleanupPeriodDays}) that reclaims expired spill files without delaying
+ * service availability; the sweep is owned by the plugin fiber and awaited
+ * during disposal, so a fiber unload never returns before it quiesces.
  */
 export class LocalSpillStore extends SpillStore {
   static Config: z<Config> = z.object({
     root: z.string(),
+    cleanupPeriodDays: z.number().default(30),
   })
 
   /** Resolved absolute spill root (config `root`, else the private default), fixed at construction. */
   readonly root: string
 
+  /** Validated config (schemastery applied the `cleanupPeriodDays` default before construction). */
+  readonly config: ResolvedConfig
+
+  /**
+   * The in-flight (or settled) startup cleanup sweep. Held so disposal can await
+   * it; `undefined` when cleanup is disabled (`cleanupPeriodDays === 0`).
+   */
+  private cleanup: Promise<void> | undefined
+
   constructor(ctx: Context, config: Config) {
     super(ctx)
+    // schemastery (static Config) has already filled `cleanupPeriodDays`; the
+    // cast records that runtime fact for exactOptionalPropertyTypes.
+    this.config = config as ResolvedConfig
+    if (!Number.isInteger(this.config.cleanupPeriodDays) || this.config.cleanupPeriodDays < 0) {
+      throw new Error(`spill-local: cleanupPeriodDays must be a non-negative integer (got ${this.config.cleanupPeriodDays})`)
+    }
     this.root = config.root !== undefined ? resolve(config.root) : privateRoot()
+
+    // One best-effort startup sweep, owned by the fiber. The generator body runs
+    // at activation but does NOT await the sweep — it launches it and yields an
+    // async disposer that awaits the SAME promise, so service availability is
+    // never delayed yet a fiber unload reaches quiescence (no sweep I/O outlives
+    // the fiber). Disabled (`cleanupPeriodDays === 0`) yields a no-op disposer.
+    ctx.effect(function* (this: LocalSpillStore) {
+      if (this.config.cleanupPeriodDays > 0) {
+        const warn: WarnFn = (message) => { this.ctx.logger.warn(message) }
+        this.cleanup = this.runCleanup(warn)
+      }
+      yield async () => { await this.cleanup }
+    }.bind(this), 'spill-local cleanup sweep')
+  }
+
+  /**
+   * Run the one-shot cleanup: gather the roots to sweep (see {@link gatherRoots})
+   * and sweep all of them at the age cutoff. Best-effort —
+   * {@link sweepSpillRoots} contains every filesystem failure, so this never
+   * rejects and cannot fail activation or a concurrent spill write.
+   *
+   * @param warn - sink for a contained filesystem failure.
+   * @returns Resolves when the sweep finishes (never rejects).
+   */
+  private async runCleanup(warn: WarnFn): Promise<void> {
+    const cutoffMs = Date.now() - this.config.cleanupPeriodDays * MS_PER_DAY
+    const roots = await this.gatherRoots(warn)
+    await sweepSpillRoots({ roots, cutoffMs, warn })
+  }
+
+  /**
+   * The roots the startup sweep covers: the prior default `dsh-spill-*` temp
+   * roots (see {@link discoverDefaultRoots}) plus the configured/active root,
+   * de-duplicated (the active root may itself be a discovered default). A test
+   * overrides this to inject an isolated root set — and, being the sweep's one
+   * async gather point, to hold the sweep open across a disposal for the
+   * quiescence check; it is a test seam, not a deployment knob.
+   *
+   * @param warn - sink for a contained discovery failure.
+   * @returns The absolute roots to sweep.
+   */
+  protected async gatherRoots(warn: WarnFn): Promise<string[]> {
+    const discovered = await discoverDefaultRoots(warn, this.defaultRootsBase())
+    return discovered.includes(this.root) ? discovered : [...discovered, this.root]
+  }
+
+  /**
+   * The directory scanned for prior default `dsh-spill-*` roots — the OS tmpdir,
+   * where {@link privateRoot} creates them (accumulation only happens there). A
+   * test overrides this to point discovery at an isolated fixture instead of the
+   * real tmpdir; it is a test seam, not a deployment knob.
+   *
+   * @returns The base directory to scan for default spill roots.
+   */
+  protected defaultRootsBase(): string {
+    return tmpdir()
   }
 
   async saveText(input: SaveTextSpill): Promise<SpillRef> {

+ 200 - 2
packages/spill/spill-local/src/store.ts

@@ -8,10 +8,18 @@
 
 import { createHash, randomBytes } from 'node:crypto'
 import { mkdtempSync } from 'node:fs'
-import { mkdir, open } from 'node:fs/promises'
+import { lstat, mkdir, open, readdir, rmdir, unlink } from 'node:fs/promises'
 import { join } from 'node:path'
 import { tmpdir } from 'node:os'
 
+/**
+ * Filename prefix for the lazily-created private default spill roots
+ * (`mkdtemp(tmpdir()/dsh-spill-)`). Startup cleanup rediscovers these
+ * per-process roots (from prior runs that used no configured `root`) by this
+ * prefix — see {@link discoverDefaultRoots}.
+ */
+export const DEFAULT_ROOT_PREFIX = 'dsh-spill-'
+
 let defaultRoot: string | undefined
 
 /**
@@ -23,7 +31,7 @@ let defaultRoot: string | undefined
  * @returns The lazily-created private spill root.
  */
 export function privateRoot(): string {
-  defaultRoot ??= mkdtempSync(join(tmpdir(), 'dsh-spill-'))
+  defaultRoot ??= mkdtempSync(join(tmpdir(), DEFAULT_ROOT_PREFIX))
   return defaultRoot
 }
 
@@ -114,3 +122,193 @@ export async function saveTextFile(options: SaveTextOptions): Promise<SavedText>
   }
   return { path, bytes }
 }
+
+/** A one-argument warning sink — the sweep's only side effect on failure (never throws). */
+export type WarnFn = (message: string) => void
+
+/** Options for {@link sweepSpillRoots} — the roots to scan, the age cutoff, and a failure sink. */
+export interface SweepOptions {
+  /** Absolute spill roots to sweep (configured root and/or discovered default roots). */
+  roots: string[]
+  /**
+   * Epoch-millis cutoff: a regular file is deleted when its `mtime` is strictly
+   * older than this. The caller derives it from `now - cleanupPeriodDays`, so a
+   * file written exactly at the boundary is kept (only strictly-older expires).
+   */
+  cutoffMs: number
+  /** Where a contained filesystem failure is reported; the sweep itself never throws. */
+  warn: WarnFn
+}
+
+/**
+ * Delete a single path, treating a concurrent-race disappearance as success.
+ * A parallel process (or another sweep) may `unlink` the same file between our
+ * scan and our own `unlink` — ENOENT then means the goal (file gone) already
+ * holds, so it is not a failure. Any other error is reported and swallowed.
+ *
+ * @param path The absolute file path to remove.
+ * @param warn Sink for a non-ENOENT failure message.
+ * @returns Resolves once the removal was attempted (never rejects).
+ */
+async function unlinkIdempotent(path: string, warn: WarnFn): Promise<void> {
+  try {
+    await unlink(path)
+  } catch (error: unknown) {
+    /* v8 ignore start -- reached only when a file selected for deletion (a
+       regular file that passed lstat) then fails to unlink: either it raced away
+       (ENOENT) or a permission/IO fault struck between the stat and the unlink.
+       Neither is deterministically reproducible in-process. */
+    if (isErrno(error, 'ENOENT')) return
+    warn(`spill-local: failed to delete ${path}: ${String(error)}`)
+    /* v8 ignore stop */
+  }
+}
+
+/**
+ * True when `error` is a Node system error carrying the given `code`.
+ *
+ * @param error The caught value to test.
+ * @param code The `NodeJS.ErrnoException` code to match (e.g. `'ENOENT'`).
+ * @returns `true` when `error` is an `Error` whose `code` equals `code`.
+ */
+export function isErrno(error: unknown, code: string): boolean {
+  return error instanceof Error && (error as NodeJS.ErrnoException).code === code
+}
+
+/**
+ * Sweep one spill session directory: delete expired regular files, skip
+ * everything else, and report the directory empty afterward so the caller can
+ * prune it. A symlink or any non-regular entry (socket, fifo, nested dir) is
+ * left untouched — `lstat` never follows a link, so a planted symlink can
+ * neither be deleted nor redirect the age check. Every per-entry failure is
+ * contained: one unreadable file does not abort the directory.
+ *
+ * @param dir The absolute session directory to scan.
+ * @param cutoffMs Files with `mtime` strictly older than this are deleted.
+ * @param warn Sink for contained filesystem failures.
+ * @returns `true` when the directory holds no entries after the sweep (a prune candidate).
+ */
+async function sweepSessionDir(dir: string, cutoffMs: number, warn: WarnFn): Promise<boolean> {
+  let names: string[]
+  try {
+    names = await readdir(dir)
+  } catch (error: unknown) {
+    // A `session-*` entry that is not a readable directory (a stray file, or an
+    // unreadable/vanished dir) is not ours to fix — report and leave it. False
+    // keeps it out of the prune step.
+    warn(`spill-local: failed to read ${dir}: ${String(error)}`)
+    return false
+  }
+  let remaining = names.length
+  for (const name of names) {
+    const path = join(dir, name)
+    let stats
+    try {
+      stats = await lstat(path)
+    } catch (error: unknown) {
+      /* v8 ignore start -- an entry that readdir just returned then fails to
+         lstat only by racing away (ENOENT) or a permission/IO fault; keep it out
+         of the deterministic test surface. */
+      if (isErrno(error, 'ENOENT')) { remaining--; continue }
+      warn(`spill-local: failed to stat ${path}: ${String(error)}`)
+      continue
+      /* v8 ignore stop */
+    }
+    // Only regular files expire. Symlinks and other special entries are skipped
+    // (never followed) so the sweep cannot be redirected or delete a link.
+    if (!stats.isFile()) continue
+    if (stats.mtimeMs >= cutoffMs) continue
+    await unlinkIdempotent(path, warn)
+    remaining--
+  }
+  return remaining === 0
+}
+
+/**
+ * Best-effort one-shot cleanup: across each root, delete expired regular files
+ * under its `session-*` directories and prune any directory left empty. The
+ * sweep is idempotent and safe to run concurrently with live spill writes and
+ * with another process's sweep — per-file expiry preserves a fresh write even
+ * if it lands mid-sweep, and every filesystem failure is caught and reported
+ * rather than thrown, so a caller can await this during activation/disposal
+ * without it ever rejecting.
+ *
+ * @param options The roots to sweep, the age cutoff, and the failure sink.
+ * @returns Resolves when the sweep finishes (never rejects).
+ */
+export async function sweepSpillRoots(options: SweepOptions): Promise<void> {
+  const { roots, cutoffMs, warn } = options
+  for (const root of roots) {
+    let entries: string[]
+    try {
+      entries = await readdir(root)
+    } catch (error: unknown) {
+      // A root that does not exist yet (no spill ever written) is the common
+      // case, not an error: ENOENT is silent, anything else is reported.
+      if (!isErrno(error, 'ENOENT')) warn(`spill-local: failed to read root ${root}: ${String(error)}`)
+      continue
+    }
+    for (const name of entries) {
+      // Only the backend's own `session-<hash>` directories are swept; an
+      // unrelated sibling under a shared configured root is left untouched.
+      if (!name.startsWith('session-')) continue
+      const dir = join(root, name)
+      const empty = await sweepSessionDir(dir, cutoffMs, warn)
+      if (!empty) continue
+      try {
+        await rmdir(dir)
+      } catch (error: unknown) {
+        /* v8 ignore start -- prune runs only on a dir observed empty; a failure
+           here means a concurrent writer added a file (ENOTEMPTY) or a
+           permission/IO fault struck — both are races outside deterministic
+           in-process testing. */
+        if (!isErrno(error, 'ENOENT') && !isErrno(error, 'ENOTEMPTY')) {
+          warn(`spill-local: failed to prune ${dir}: ${String(error)}`)
+        }
+        /* v8 ignore stop */
+      }
+    }
+  }
+}
+
+/**
+ * Discover prior default spill roots: the `dsh-spill-*` directories directly
+ * under `base` (the OS tmpdir) that earlier runs created via {@link privateRoot}
+ * when no `root` was configured. A long-lived deployment with a configured root
+ * will find none; a series of default-root runs accumulates one per process, so
+ * the startup sweep reclaims them all. Symlinks and non-directories are excluded
+ * — only real directories the backend could have created are returned.
+ *
+ * @param warn Sink for a failure reading `base` (returns `[]` on failure).
+ * @param base The directory to scan; defaults to the OS tmpdir (a test seam).
+ * @returns Absolute paths of the discovered default roots (possibly empty).
+ */
+export async function discoverDefaultRoots(warn: WarnFn, base: string = tmpdir()): Promise<string[]> {
+  let entries: string[]
+  try {
+    entries = await readdir(base)
+  } catch (error: unknown) {
+    warn(`spill-local: failed to scan ${base} for default roots: ${String(error)}`)
+    return []
+  }
+  const roots: string[] = []
+  for (const name of entries) {
+    if (!name.startsWith(DEFAULT_ROOT_PREFIX)) continue
+    const path = join(base, name)
+    let stats
+    try {
+      // lstat, not stat: a symlink named `dsh-spill-*` must not be treated as a
+      // root we then sweep (it could point anywhere).
+      stats = await lstat(path)
+    } catch (error: unknown) {
+      /* v8 ignore start -- an entry readdir just returned fails to lstat only by
+         racing away (ENOENT) or a permission/IO fault; not deterministically
+         reproducible. */
+      if (!isErrno(error, 'ENOENT')) warn(`spill-local: failed to stat default root ${path}: ${String(error)}`)
+      continue
+      /* v8 ignore stop */
+    }
+    if (stats.isDirectory()) roots.push(path)
+  }
+  return roots
+}

+ 300 - 9
packages/spill/spill-local/tests/spill-local.spec.ts

@@ -2,19 +2,33 @@
  * Tests for the LOCAL spill backend: `saveText` writes a session-scoped file and
  * returns a locator + byte length + retrieval hint, filename sanitization
  * neutralizes traversal, the configured `root` is honored (and the private
- * default when omitted), and a storage failure rejects. The Cordis-free
- * `store.ts` helpers are exercised directly for the naming/encoding edge cases.
+ * default when omitted), and a storage failure rejects. The startup cleanup
+ * sweep expires old files, prunes empty dirs, skips symlinks/unknown entries,
+ * discovers prior default roots, contains filesystem failures, and is awaited on
+ * disposal without blocking activation. The Cordis-free `store.ts` helpers are
+ * exercised directly for the naming/encoding and sweep edge cases.
  */
 
-import { describe, expect, it, beforeEach, afterEach } from 'vitest'
+import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
-import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { basename, dirname, isAbsolute, join, normalize } from 'node:path'
 import { CallId } from '@deepseek-ai/dsh-llm'
 import { SessionId } from '@deepseek-ai/dsh-session'
 import type { SaveTextSpill } from '@deepseek-ai/dsh-spill'
-import LocalSpillStore, { encodeSegment, privateRoot, saveTextFile, sessionDir } from '@deepseek-ai/dsh-spill-local'
+import LocalSpillStore, {
+  DEFAULT_ROOT_PREFIX,
+  discoverDefaultRoots,
+  encodeSegment,
+  isErrno,
+  privateRoot,
+  saveTextFile,
+  sessionDir,
+  sweepSpillRoots,
+} from '@deepseek-ai/dsh-spill-local'
+
+const DAY_MS = 24 * 60 * 60 * 1000
 
 let root: string
 
@@ -25,6 +39,13 @@ afterEach(() => {
   rmSync(root, { recursive: true, force: true })
 })
 
+/** Write a file with an mtime `ageDays` in the past (fractional allowed). */
+function writeAged(path: string, content: string, ageDays: number): void {
+  writeFileSync(path, content)
+  const when = (Date.now() - ageDays * DAY_MS) / 1000
+  utimesSync(path, when, when)
+}
+
 function request(overrides: Partial<SaveTextSpill> = {}): SaveTextSpill {
   return {
     owner: { sessionId: SessionId('sess-1') },
@@ -113,9 +134,11 @@ describe('privateRoot', () => {
 })
 
 describe('LocalSpillStore service', () => {
+  // These tests exercise save/root resolution, not cleanup; disabling the sweep
+  // (cleanupPeriodDays: 0) keeps them from scanning/sweeping the real tmpdir.
   it('registers as ctx.spillStore and saves under the configured root', async () => {
     const ctx = new Context()
-    await ctx.plugin(LocalSpillStore, { root })
+    await ctx.plugin(LocalSpillStore, { root, cleanupPeriodDays: 0 })
     const ref = await ctx.spillStore.saveText(request())
     expect(dirname(ref.locator)).toBe(sessionDir(root, 'sess-1'))
     expect(readFileSync(ref.locator, 'utf8')).toBe('the full body')
@@ -125,13 +148,13 @@ describe('LocalSpillStore service', () => {
 
   it('resolves a relative configured root to absolute', async () => {
     const ctx = new Context()
-    await ctx.plugin(LocalSpillStore, { root: '.' })
+    await ctx.plugin(LocalSpillStore, { root: '.', cleanupPeriodDays: 0 })
     expect(isAbsolute((ctx.spillStore as LocalSpillStore).root)).toBe(true)
   })
 
   it('falls back to the private root when none is configured', async () => {
     const ctx = new Context()
-    await ctx.plugin(LocalSpillStore, {})
+    await ctx.plugin(LocalSpillStore, { cleanupPeriodDays: 0 })
     expect((ctx.spillStore as LocalSpillStore).root).toBe(privateRoot())
   })
 
@@ -139,7 +162,275 @@ describe('LocalSpillStore service', () => {
     const ctx = new Context()
     // A file (not a dir) as the root makes mkdir under it fail — a real storage error.
     const filePath = (await saveTextFile({ root, sessionId: 's', suggestedName: 'f', content: 'x' })).path
-    await ctx.plugin(LocalSpillStore, { root: filePath })
+    await ctx.plugin(LocalSpillStore, { root: filePath, cleanupPeriodDays: 0 })
     await expect(ctx.spillStore.saveText(request())).rejects.toThrow()
   })
+
+  it('rejects a negative or fractional cleanupPeriodDays at load', async () => {
+    await expect(new Context().plugin(LocalSpillStore, { root, cleanupPeriodDays: -1 }))
+      .rejects.toThrow(/cleanupPeriodDays must be a non-negative integer/)
+    await expect(new Context().plugin(LocalSpillStore, { root, cleanupPeriodDays: 1.5 }))
+      .rejects.toThrow(/cleanupPeriodDays must be a non-negative integer/)
+  })
+
+  it('defaults cleanupPeriodDays to 30', async () => {
+    const ctx = new Context()
+    // Point discovery at an empty isolated base so the default sweep does not
+    // touch the real tmpdir; assert only that the default landed on config.
+    const emptyBase = mkdtempSync(join(tmpdir(), 'dsh-empty-'))
+    class Isolated extends LocalSpillStore {
+      protected override defaultRootsBase(): string { return emptyBase }
+    }
+    try {
+      const fiber = await ctx.plugin(Isolated, { root })
+      const store = ctx.spillStore as LocalSpillStore
+      await fiber.dispose()
+      expect(store.config.cleanupPeriodDays).toBe(30)
+    } finally {
+      rmSync(emptyBase, { recursive: true, force: true })
+    }
+  })
+
+  it('the default discovery base is the OS tmpdir', async () => {
+    // Every hermetic sweep test overrides defaultRootsBase(); pin its production
+    // default here (scan the OS tmpdir) without letting the sweep touch tmpdir.
+    class Exposed extends LocalSpillStore {
+      base(): string { return this.defaultRootsBase() }
+      protected override async gatherRoots(): Promise<string[]> { return [] }
+    }
+    const ctx = new Context()
+    const fiber = await ctx.plugin(Exposed, { root, cleanupPeriodDays: 30 })
+    const store = ctx.spillStore as Exposed
+    await fiber.dispose()
+    expect(store.base()).toBe(tmpdir())
+  })
+
+  it('routes a sweep filesystem failure to ctx.logger.warn (service warn wiring)', async () => {
+    // A `session-*` entry that is a FILE, not a directory, makes readdir throw
+    // ENOTDIR inside the real sweep. The service's warn closure must forward it
+    // to ctx.logger.warn, and disposal must still settle cleanly.
+    const stray = join(root, 'session-stray'); writeFileSync(stray, 'x')
+    const ctx = new Context()
+    const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
+    class Discovering extends LocalSpillStore {
+      protected override async gatherRoots(): Promise<string[]> { return [this.root] }
+    }
+    const fiber = await ctx.plugin(Discovering, { root, cleanupPeriodDays: 30 })
+    await fiber.dispose()
+    expect(warn).toHaveBeenCalledWith(expect.stringContaining('failed to read'))
+  })
+})
+
+/**
+ * A store whose sweep covers exactly the roots handed in (no real-tmpdir scan) —
+ * the hermetic seam for the cleanup tests. `barrier`, when set, holds the async
+ * gather open so a test can prove disposal awaits the sweep.
+ */
+class SweptStore extends LocalSpillStore {
+  static sweepRoots: string[] = []
+  static barrier: Promise<void> | undefined
+  protected override async gatherRoots(): Promise<string[]> {
+    if (SweptStore.barrier) await SweptStore.barrier
+    return SweptStore.sweepRoots
+  }
+}
+
+async function runSweep(roots: string[], cleanupPeriodDays = 30): Promise<void> {
+  SweptStore.sweepRoots = roots
+  SweptStore.barrier = undefined
+  const ctx = new Context()
+  const fiber = await ctx.plugin(SweptStore, { root, cleanupPeriodDays })
+  // Disposal awaits the fiber-owned sweep, so after this the sweep has run.
+  await fiber.dispose()
+}
+
+describe('startup cleanup sweep', () => {
+  it('deletes files older than the cutoff and keeps fresh ones', async () => {
+    const dir = sessionDir(root, 'sess-1')
+    mkdirSync(dir, { recursive: true })
+    const old = join(dir, 'old.txt'); writeAged(old, 'x', 40)
+    const fresh = join(dir, 'fresh.txt'); writeAged(fresh, 'y', 1)
+    await runSweep([root])
+    expect(existsSync(old)).toBe(false)
+    expect(existsSync(fresh)).toBe(true)
+  })
+
+  it('keeps a file exactly at the boundary (only strictly-older expires)', async () => {
+    const dir = sessionDir(root, 'sess-1')
+    mkdirSync(dir, { recursive: true })
+    // mtime == cutoff: mtimeMs >= cutoffMs holds, so it is kept. Age it just
+    // under 30d to avoid the sub-millisecond race of "exactly now - 30d".
+    const boundary = join(dir, 'boundary.txt'); writeAged(boundary, 'x', 29.9)
+    await runSweep([root])
+    expect(existsSync(boundary)).toBe(true)
+  })
+
+  it('disabled (cleanupPeriodDays: 0) sweeps nothing', async () => {
+    const dir = sessionDir(root, 'sess-1')
+    mkdirSync(dir, { recursive: true })
+    const old = join(dir, 'old.txt'); writeAged(old, 'x', 400)
+    await runSweep([root], 0)
+    expect(existsSync(old)).toBe(true)
+  })
+
+  it('prunes a directory left empty, keeps one with a surviving file', async () => {
+    const emptied = sessionDir(root, 'emptied')
+    const kept = sessionDir(root, 'kept')
+    mkdirSync(emptied, { recursive: true })
+    mkdirSync(kept, { recursive: true })
+    writeAged(join(emptied, 'a.txt'), 'x', 40)
+    writeAged(join(kept, 'fresh.txt'), 'y', 1)
+    await runSweep([root])
+    expect(existsSync(emptied)).toBe(false)
+    expect(existsSync(kept)).toBe(true)
+  })
+
+  it('skips symlinks and non-session entries', async () => {
+    const dir = sessionDir(root, 'sess-1')
+    mkdirSync(dir, { recursive: true })
+    // A symlink pointing at an old target must NOT be followed or deleted.
+    const target = join(root, 'target.txt'); writeAged(target, 'keep', 40)
+    const link = join(dir, 'link.txt'); symlinkSync(target, link)
+    // A non-session sibling directory under a shared root is untouched.
+    const unrelated = join(root, 'not-a-session'); mkdirSync(unrelated)
+    const unrelatedOld = join(unrelated, 'old.txt'); writeAged(unrelatedOld, 'x', 40)
+    await runSweep([root])
+    // The symlink itself survives (lstat sees a link, not a file), so its dir is
+    // not empty and is not pruned; the link target survives too.
+    expect(existsSync(link)).toBe(true)
+    expect(existsSync(target)).toBe(true)
+    expect(existsSync(unrelatedOld)).toBe(true)
+  })
+
+  it('covers the configured root AND discovered default roots (real gatherRoots)', async () => {
+    // A prior default root under an isolated fake tmpdir + the configured root.
+    // This test drives the REAL gatherRoots/discoverDefaultRoots path by seaming
+    // only the tmpdir scan base, not gatherRoots itself.
+    const fakeTmp = mkdtempSync(join(tmpdir(), 'dsh-faketmp-'))
+    const priorDefault = join(fakeTmp, `${DEFAULT_ROOT_PREFIX}ABCDEF`)
+    const priorDir = sessionDir(priorDefault, 'old-sess')
+    mkdirSync(priorDir, { recursive: true })
+    const priorOld = join(priorDir, 'old.txt'); writeAged(priorOld, 'x', 40)
+    const cfgDir = sessionDir(root, 'sess-1')
+    mkdirSync(cfgDir, { recursive: true })
+    const cfgOld = join(cfgDir, 'old.txt'); writeAged(cfgOld, 'x', 40)
+    class Discovering extends LocalSpillStore {
+      protected override defaultRootsBase(): string { return fakeTmp }
+    }
+    try {
+      const ctx = new Context()
+      const fiber = await ctx.plugin(Discovering, { root, cleanupPeriodDays: 30 })
+      await fiber.dispose()
+      expect(existsSync(priorOld)).toBe(false)
+      expect(existsSync(cfgOld)).toBe(false)
+    } finally {
+      rmSync(fakeTmp, { recursive: true, force: true })
+    }
+  })
+
+  it('de-dups when the active root is itself a discovered default (real gatherRoots)', async () => {
+    // The configured root lives directly under the seamed base and matches the
+    // default prefix, so discovery finds it AND it is the active root — the sweep
+    // must run once, not choke on the duplicate.
+    const fakeTmp = mkdtempSync(join(tmpdir(), 'dsh-faketmp-'))
+    const activeDefault = join(fakeTmp, `${DEFAULT_ROOT_PREFIX}ACTIVE`)
+    const dir = sessionDir(activeDefault, 'sess-1')
+    mkdirSync(dir, { recursive: true })
+    const old = join(dir, 'old.txt'); writeAged(old, 'x', 40)
+    class Discovering extends LocalSpillStore {
+      protected override defaultRootsBase(): string { return fakeTmp }
+    }
+    try {
+      const ctx = new Context()
+      const fiber = await ctx.plugin(Discovering, { root: activeDefault, cleanupPeriodDays: 30 })
+      await fiber.dispose()
+      expect(existsSync(old)).toBe(false)
+    } finally {
+      rmSync(fakeTmp, { recursive: true, force: true })
+    }
+  })
+
+  it('does not block activation but is awaited on disposal (quiescence)', async () => {
+    const dir = sessionDir(root, 'sess-1')
+    mkdirSync(dir, { recursive: true })
+    const old = join(dir, 'old.txt'); writeAged(old, 'x', 40)
+
+    // Hold the sweep open behind a barrier we control.
+    let release!: () => void
+    SweptStore.sweepRoots = [root]
+    SweptStore.barrier = new Promise<void>((resolve) => { release = resolve })
+
+    const ctx = new Context()
+    const fiber = await ctx.plugin(SweptStore, { root, cleanupPeriodDays: 30 })
+    // Activation returned while the sweep is still parked: service is usable and
+    // the old file is untouched so far.
+    expect(existsSync(old)).toBe(true)
+    const ref = await ctx.spillStore.saveText(request())
+    expect(readFileSync(ref.locator, 'utf8')).toBe('the full body')
+
+    // Disposal must AWAIT the sweep: release the barrier, and dispose only
+    // settles after the sweep deleted the old file.
+    release()
+    await fiber.dispose()
+    expect(existsSync(old)).toBe(false)
+  })
+
+  it('a filesystem failure is contained (logged, never thrown) and does not fail a spill write', async () => {
+    const warn = vi.fn()
+    // A path that is a FILE, not a directory: readdir(root) throws ENOTDIR. The
+    // sweep must log and return, never reject.
+    const filePath = join(root, 'not-a-dir'); writeFileSync(filePath, 'x')
+    await expect(sweepSpillRoots({ roots: [filePath], cutoffMs: Date.now(), warn })).resolves.toBeUndefined()
+    expect(warn).toHaveBeenCalledWith(expect.stringContaining('failed to read root'))
+  })
+
+  it('a nonexistent root is silent (the common no-spill-yet case)', async () => {
+    const warn = vi.fn()
+    await sweepSpillRoots({ roots: [join(root, 'never-created')], cutoffMs: Date.now(), warn })
+    expect(warn).not.toHaveBeenCalled()
+  })
+
+  it('a session entry that is a file (not a dir) is reported, not pruned', async () => {
+    const warn = vi.fn()
+    // `session-strayfile` matches the session- prefix but is a regular file, so
+    // readdir on it throws ENOTDIR: reported, left in place (not empty → no prune).
+    const stray = join(root, 'session-strayfile'); writeFileSync(stray, 'x')
+    await sweepSpillRoots({ roots: [root], cutoffMs: Date.now(), warn })
+    expect(warn).toHaveBeenCalledWith(expect.stringContaining('failed to read'))
+    expect(existsSync(stray)).toBe(true)
+  })
 })
+
+describe('discoverDefaultRoots', () => {
+  it('returns only real dsh-spill-* directories, excluding symlinks and non-matches', async () => {
+    const base = mkdtempSync(join(tmpdir(), 'dsh-disc-'))
+    try {
+      const realRoot = join(base, `${DEFAULT_ROOT_PREFIX}real`); mkdirSync(realRoot)
+      mkdirSync(join(base, 'unrelated-dir'))
+      writeFileSync(join(base, `${DEFAULT_ROOT_PREFIX}file`), 'x') // matches prefix but is a file
+      symlinkSync(realRoot, join(base, `${DEFAULT_ROOT_PREFIX}link`)) // matches prefix but is a symlink
+      const found = await discoverDefaultRoots(() => {}, base)
+      expect(found).toEqual([realRoot])
+    } finally {
+      rmSync(base, { recursive: true, force: true })
+    }
+  })
+
+  it('returns [] and warns when the base is unreadable', async () => {
+    const warn = vi.fn()
+    const missing = join(root, 'no-such-base')
+    expect(await discoverDefaultRoots(warn, missing)).toEqual([])
+    expect(warn).toHaveBeenCalledWith(expect.stringContaining('failed to scan'))
+  })
+})
+
+describe('isErrno', () => {
+  it('matches a Node system error by code and rejects non-matches', () => {
+    const err = Object.assign(new Error('boom'), { code: 'ENOENT' })
+    expect(isErrno(err, 'ENOENT')).toBe(true)
+    expect(isErrno(err, 'EPERM')).toBe(false)
+    expect(isErrno('not an error', 'ENOENT')).toBe(false)
+    expect(isErrno(new Error('no code'), 'ENOENT')).toBe(false)
+  })
+})
+