Store each session's projection_cache.json under the cache's own root tree
(<root>/<session-id>/projection_cache.json, wired to dshHomePath('projections')
in the base bundle) instead of beside the session log via
sessionPersistence.locate(). The cache owns its directory layout, keys
directories by the code-generated session id, and never consults the
persistence layer; the service now injects only sessionProjections and
sessions.
Drop the coldSnapshot method and its readFrom-tail fold ladder: every cold
consumer refolds from the log itself, so the cache only serves the listing
read (cachedSnapshot, one async file read per session) and the write side.
Fail-soft durability, per-path write serialization, in-flight drain, and
atomic 0600 writes are unchanged; the chain cleanup now observes its own
rejection so a failed write cannot surface as an unhandled error.
dsh-session-persistence leaves peer/dev dependencies and the tsconfig
reference; dsh-atomic-write moves to peerDependencies. Config gains a
required root.
@deepseek-ai/dsh-session-projection-cache
English | 中文
The persisted projection cache (ctx.sessionProjectionCache): durable checkpoints of every projection unit's state, one projection_cache.json per session under the cache's own storage root (<root>/<session-id>/projection_cache.json). The cache owns its directory tree and never consults the persistence layer. Design authority: the session-projection RFC (persisted projection cache section).
A stored row (key → {ver, seq, val}) is a fold shortcut, never an authority: possibly stale (seq says exactly how stale) but never wrong. Consequences the implementation commits to:
- Every background write is fail-soft. A failed durable write logs a warning and keeps the cache stale; the next write self-heals. A crash between writes costs a longer tail replay, never a wrong value.
- A
vermismatch against the live unit'sstateVersiondiscards, never migrates. A unit bump invalidates its rows at read time; the key refolds from the log. - A row must pass the live unit's
stateSchema. A malformed file reads as "no cache row", so the cold path refolds from the log. - Whole-record writes. Each write atomically replaces the session's cache file (the registry cut is always complete), snapshotted through the lossless-JSON boundary — a unit state violating the plain-JSON contract fails loud. Writes to one cache file serialize, so a newer cut never lands before an older one.
- Records are bound to a log lifecycle, not just an id. Each record stores the header identity (
createdAt,cwd) it was folded from; every read validates it (the live header is the witness) before accepting a record, so a deleted-then-recreated id cannot let an old record seed state folded from an unrelated log. - The log leads, the cache follows. A live checkpoint flushes the session's buffered events durably BEFORE the cache file lands, so a crash can leave the cache behind the log (a longer tail replay) but never ahead of it.
- The cache owns its tree, private by default. Session directories and cache files are created owner-only (
0o700/0o600). The cache does not depend on which persistence backend is mounted — nolocate, no per-session-dir probing.
Write policy
Two mandatory points, throttled in between:
| Trigger | Nature |
|---|---|
turn/end |
Mandatory — the turn-final value is what listing reads want. |
| Session disposal (detach) | Mandatory — the live-to-cold moment; after it the cache serves this session's final cut. |
writeEveryEvents committed events |
Config throttle (count). |
writeIntervalMs since the first dirty event |
Config throttle (interval). |
root and both throttle triggers are required Config fields (no defaults): the cache root and flush cadence are deployment choices stated in cordis.yml.
Listing read (cachedSnapshot(meta))
One file read per session: client values viewed straight from the identity-matching stored record (version- and state-schema-matching keys only), returned as a {asOfSeq, values} cut — asOfSeq is the lowest served-row watermark, so a client seeding its per-session value store under higher-seq-wins can never let a stale list block overwrite a newer push frame. Host-only rows are never returned. undefined when no usable client row exists (unknown id, unrelated lifecycle, missing or malformed file, or no usable rows); the api-proxy list carrier turns that into an absent column.
write(session) is the synchronous-cut checkpoint both mandatory points use; carriers may call it directly (not fail-soft — the fail-soft wrappers own containment).
Composition
- id: session-projection-cache
name: '@deepseek-ai/dsh-session-projection-cache'
config:
root: !!js dshHomePath('projections')
writeEveryEvents: 200
writeIntervalMs: 5000
Injects sessionProjections, sessions. Without this row the projection system runs live-only (watermark cache; cold reads fall back to full log loads wherever a carrier implements them).
Model Experience
None, as the cache only persists host-side read models of already-logged session state and touches no prompt, message, schema, stream, or tool result.
KV Cache effect
None; the cache never assembles or sends provider requests.
Known Limitations and Deferred Work
- No eviction or retention surface — records accumulate per session; pruning stored checkpoints is out-of-band maintenance, same stance as session persistence itself.
- Interval throttle is per-session coarse — the timer arms at the first dirty event after a clean write; a steady sub-threshold trickle writes once per interval, not a sliding window.
- No cache-side cold refold — the cache serves and refreshes its files but never reads the session log (it does not depend on the persistence layer); a consumer that needs a guaranteed cold snapshot refolds from the log itself.