Files
deepseek-harness/packages/session/session-projection-cache
_Kerman 1201ecc828 fix(session-projection-cache): store checkpoints on a per-record storage domain
Restore the storage-domain medium the file-root design replaced: the cache
opens the session_projcache domain (per-record layout — one document per
session under the json backend root) and checkpoint writes land through
the domain's write chain. Reads and writes now share ONE coherent state:
cachedSnapshot reads synchronously from the domain's in-memory tables,
and every write is durability-first-then-memory, so a read can never go
around the write chain to the medium. The hand-rolled write chains,
in-flight tracking, per-session file paths, owner-only file modes, and
the sqlite no-path special case are gone; Config.root is removed and the
domain's version stamp makes a checkpointRecord bump discard stale
sessions per record instead of rejecting the whole medium. The async
ripple of the old file read is reverted: api-proxy's listing column and
subagent's cold identity read go back to synchronous cachedSnapshot.
2026-08-20 13:47:47 +08:00
..

@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 ver mismatch against the live unit's stateVersion discards, 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 — no locate, 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.