diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 3fc4841f43..1b8a432cef 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -86,7 +86,7 @@ async function waitForCacheRow( header: SessionHeader, ): Promise { const deadline = Date.now() + 10_000 - while (scaffold.ctx.sessionProjectionCache.cachedSnapshot(header, SessionLogOffset(0)) === undefined) { + while (scaffold.ctx.sessionProjectionCache.cachedSnapshot(header) === undefined) { if (Date.now() >= deadline) throw new Error(`cache row for "${header.id}" did not land`) await new Promise(resolve => setTimeout(resolve, 10)) } diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 106e1545fb..ecb1a48a7b 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/config-catalog.md -config-catalog.md: dad6b57ae0712e04ad16729b4067c541f83c68d6 +config-catalog.md: 92b24a0948883b2974c5cb8ab650bd3db3b50326 config-catalog.zh.md: 4ba0378810f3db4797c0d41fa5230f7069438b3f diff --git a/docs/config-catalog.md b/docs/config-catalog.md index dad6b57ae0..92b24a0948 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2232,7 +2232,7 @@ export interface Config { } ``` -Source: [`packages/session/session-projection-cache/src/index.ts:63`](../packages/session/session-projection-cache/src/index.ts) +Source: [`packages/session/session-projection-cache/src/index.ts:75`](../packages/session/session-projection-cache/src/index.ts) diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 808a5bb76f..b0c4faff71 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/subsystems/session-projection.md -session-projection.md: cb3ac37d593a2b5aae6725854c31a3a39ae3152b -session-projection.zh.md: 5051dab59316840267d16d201a17717742565059 +session-projection.md: b46d8df330de3586691ac7e708e0b5de6c76b33f +session-projection.zh.md: 62fa2ff74ab627cc2a83f76cf4066a3ffc3020f0 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index cb3ac37d59..b46d8df330 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -122,20 +122,24 @@ The persisted projection cache service. Opens the `session_projcache` domain at ```ts cordis-catalog /** * The zero-I/O listing read: whole values viewed straight from the stored - * rows (version-matching keys only), each cut carried with its watermark so - * a client value store can seed under its higher-seq-wins rule — as stale - * as the last durable checkpoint but never wrong, and never from an - * unrelated log (the caller's header is the identity witness). Fresher - * paths (the history tail baseline) supersede these values whenever a - * session is actually opened. + * rows (version-matching keys only) of the record bound to the caller's + * lifecycle. The header is the only identity witness a listing holds, so + * this face matches the lifecycle identity (`formatVersion`, `createdAt`, + * `cwd`, `isSeeded`) and not the inherited cut: within one format + * generation the cut is fixed at fork time, so it distinguishes no + * lifecycle the other fields do not, and a viewed value never seeds a fold. + * The view is as stale as the last durable checkpoint but never wrong and + * never from an unrelated log. It is served as a cached block + * (`asOfSeq: -1`): the header cannot vouch that the stored row sequence is + * comparable with the caller's log, so the block claims no watermark and + * any sequenced value (a history baseline, a control baseline, a frame) + * supersedes it once the session is opened. * @param meta - the listed session's header (identity witness; no log read). - * @param inheritedEventCount - exact inherited prefix length that completes - * the checkpoint identity. * @param keys - optional projection keys required by the caller's audience. - * @returns the cut (`asOfSeq` = lowest served-row watermark), or - * `undefined` when no usable row exists for this lifecycle. + * @returns the cached block, or `undefined` when no usable row exists for + * this lifecycle at the current Session format. */ -cachedSnapshot( meta: SessionHeader, inheritedEventCount: SessionLogOffset, keys?: readonly Extract[], ): ProjectionSnapshot | undefined +cachedSnapshot( meta: SessionHeader, keys?: readonly Extract[], ): ProjectionSnapshot | undefined /** * Read only a predecessor checkpoint's title as a zero-I/O listing hint. @@ -146,15 +150,13 @@ cachedSnapshot( meta: SessionHeader, inheritedEventCount: SessionLogOffset, keys * fact from this Session. The registry still requires the current title * projection's row version and schema. No other predecessor projection is * exposed: format normalization can change their current meaning, and the - * strict {@link cachedSnapshot} / hydration paths continue to reject them. + * {@link cachedSnapshot} / hydration paths continue to reject them. * @param meta - authoritative listed Session header. - * @param inheritedEventCount - exact inherited cut completing the lifecycle identity. - * @returns a title-only checkpoint view with `asOfSeq: -1`, or `undefined` - * when the record is current, newer, unrelated, missing, or incompatible - * with the title unit. The sentinel avoids reusing a sequence that a - * cardinality-changing Session migration may have remapped. + * @returns a title-only cached block (`asOfSeq: -1`), or `undefined` when + * the record is current, newer, unrelated, missing, or incompatible with + * the title unit. */ -cachedPredecessorTitle( meta: SessionHeader, inheritedEventCount: SessionLogOffset, ): ProjectionSnapshot | undefined +cachedPredecessorTitle(meta: SessionHeader): ProjectionSnapshot | undefined /** * Hydrate projection cells for an already-prepared Session without another diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 5051dab593..62fa2ff74a 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -122,20 +122,24 @@ The persisted projection cache service. Opens the `session_projcache` domain at ```ts cordis-catalog /** * The zero-I/O listing read: whole values viewed straight from the stored - * rows (version-matching keys only), each cut carried with its watermark so - * a client value store can seed under its higher-seq-wins rule — as stale - * as the last durable checkpoint but never wrong, and never from an - * unrelated log (the caller's header is the identity witness). Fresher - * paths (the history tail baseline) supersede these values whenever a - * session is actually opened. + * rows (version-matching keys only) of the record bound to the caller's + * lifecycle. The header is the only identity witness a listing holds, so + * this face matches the lifecycle identity (`formatVersion`, `createdAt`, + * `cwd`, `isSeeded`) and not the inherited cut: within one format + * generation the cut is fixed at fork time, so it distinguishes no + * lifecycle the other fields do not, and a viewed value never seeds a fold. + * The view is as stale as the last durable checkpoint but never wrong and + * never from an unrelated log. It is served as a cached block + * (`asOfSeq: -1`): the header cannot vouch that the stored row sequence is + * comparable with the caller's log, so the block claims no watermark and + * any sequenced value (a history baseline, a control baseline, a frame) + * supersedes it once the session is opened. * @param meta - the listed session's header (identity witness; no log read). - * @param inheritedEventCount - exact inherited prefix length that completes - * the checkpoint identity. * @param keys - optional projection keys required by the caller's audience. - * @returns the cut (`asOfSeq` = lowest served-row watermark), or - * `undefined` when no usable row exists for this lifecycle. + * @returns the cached block, or `undefined` when no usable row exists for + * this lifecycle at the current Session format. */ -cachedSnapshot( meta: SessionHeader, inheritedEventCount: SessionLogOffset, keys?: readonly Extract[], ): ProjectionSnapshot | undefined +cachedSnapshot( meta: SessionHeader, keys?: readonly Extract[], ): ProjectionSnapshot | undefined /** * Read only a predecessor checkpoint's title as a zero-I/O listing hint. @@ -146,15 +150,13 @@ cachedSnapshot( meta: SessionHeader, inheritedEventCount: SessionLogOffset, keys * fact from this Session. The registry still requires the current title * projection's row version and schema. No other predecessor projection is * exposed: format normalization can change their current meaning, and the - * strict {@link cachedSnapshot} / hydration paths continue to reject them. + * {@link cachedSnapshot} / hydration paths continue to reject them. * @param meta - authoritative listed Session header. - * @param inheritedEventCount - exact inherited cut completing the lifecycle identity. - * @returns a title-only checkpoint view with `asOfSeq: -1`, or `undefined` - * when the record is current, newer, unrelated, missing, or incompatible - * with the title unit. The sentinel avoids reusing a sequence that a - * cardinality-changing Session migration may have remapped. + * @returns a title-only cached block (`asOfSeq: -1`), or `undefined` when + * the record is current, newer, unrelated, missing, or incompatible with + * the title unit. */ -cachedPredecessorTitle( meta: SessionHeader, inheritedEventCount: SessionLogOffset, ): ProjectionSnapshot | undefined +cachedPredecessorTitle(meta: SessionHeader): ProjectionSnapshot | undefined /** * Hydrate projection cells for an already-prepared Session without another diff --git a/packages/api/session-controller/src/list.ts b/packages/api/session-controller/src/list.ts index 530414c52d..957d577856 100644 --- a/packages/api/session-controller/src/list.ts +++ b/packages/api/session-controller/src/list.ts @@ -3,7 +3,6 @@ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent-presets' import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' -import { SessionLogOffset } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-session-projection-cache' @@ -273,11 +272,10 @@ export class ApiSessionList { ): SessionProjectionHints | undefined { try { const cache = this.ctx.get('sessionProjectionCache') + // A cold row reads the cache by header alone; the cache serves seeded + // and unseeded lifecycles alike because a listing never seeds a fold. const block = session === undefined - ? header.isSeeded - ? undefined - : cache?.cachedSnapshot(header, SessionLogOffset(0)) - ?? cache?.cachedPredecessorTitle(header, SessionLogOffset(0)) + ? cache?.cachedSnapshot(header) ?? cache?.cachedPredecessorTitle(header) : this.ctx.sessionProjections.cachedSnapshot(session) return block !== undefined && Object.keys(block.values).length > 0 ? { diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index c8be6f2252..00988a62fd 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -152,6 +152,12 @@ describe('sessions.list cold merge', () => { if (meta.id === sid('cached-conversation')) { return { asOfSeq: 1, values: { sessionListMetadata: { blank: false, lastPromptAt: 1000 } } } } + if (meta.id === sid('seeded-cold')) { + return { + asOfSeq: -1, + values: { title: 'Forked title', sessionListMetadata: { blank: false, lastPromptAt: 1200 } }, + } + } return undefined }, cachedPredecessorTitle: () => undefined, @@ -172,10 +178,17 @@ describe('sessions.list cold merge', () => { origin: 'subagent', }) expect(byId['missing-cwd']).toBeUndefined() - // A cold seeded header never consults the cache: its cut is not 0, so a - // cut-0 lookup would alias a different projection identity. - expect(byId['seeded-cold']).toMatchObject({ blank: false, updatedAt: 450 }) - expect(cacheCalls).not.toContain('seeded-cold') + // A cold seeded header reads the cache by header alone, like any other + // cold row: the cache binds the lifecycle, and a listing never seeds a fold. + expect(byId['seeded-cold']).toMatchObject({ + blank: false, + updatedAt: 1200, + projections: { + asOfSeq: -1, + values: { title: 'Forked title', sessionListMetadata: { blank: false, lastPromptAt: 1200 } }, + }, + }) + expect(cacheCalls).toContain('seeded-cold') expect(inspect).not.toHaveBeenCalled() }) diff --git a/packages/context/session-reference/src/index.ts b/packages/context/session-reference/src/index.ts index 848e4ddc15..f2082d85e2 100644 --- a/packages/context/session-reference/src/index.ts +++ b/packages/context/session-reference/src/index.ts @@ -11,7 +11,6 @@ import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { createUserMessage, freezeMessage, LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, LlmResolvedModelInfo, UserMessage } from '@deepseek-ai/dsh-llm' -import { SessionLogOffset } from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session' // Type-only: the `title` projection key plus the live registry and durable // cache Context merges — the two projection faces discovery labels from. @@ -239,9 +238,8 @@ export class SessionReferenceResolver extends TypertRemoteService { * Nothing else is attempted. Folding a title from a log costs the whole * log, and this call sits under every keystroke of `@` completion. A * session that no projection can answer for — one persisted before the - * cache was composed, or seeded straight to disk — is labeled by its id - * and cannot be found by its title until it is opened once, which - * checkpoints it. + * cache was composed — is labeled by its id and cannot be found by its + * title until it is opened once, which checkpoints it. * @param record - the listed session, live or cold. * @returns the title-backed mention label and the subagent-label-first display title. */ @@ -250,13 +248,7 @@ export class SessionReferenceResolver extends TypertRemoteService { const projections = this.ctx.get('sessionProjections') const snapshot = attached !== undefined && projections !== undefined ? projections.snapshot(attached, ['title', 'subagent']) - : record.header.isSeeded - ? undefined - : this.ctx.get('sessionProjectionCache')?.cachedSnapshot( - record.header, - SessionLogOffset(0), - ['title', 'subagent'], - ) + : this.ctx.get('sessionProjectionCache')?.cachedSnapshot(record.header, ['title', 'subagent']) const label = titleOf(snapshot) ?? record.header.id const subagent = snapshot?.values.subagent return { diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index 6ea0ed8cf2..efc8447e90 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -777,27 +777,36 @@ describe('session reference discovery and preparation', () => { })]) }) - it('labels a session no projection answers for by its id, still without a log read', async () => { + it('labels a cold seeded session from its cached title and an uncached one by id, without a log read', async () => { const ctx = await harness() const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } }) - const seeded = { + const forked = { version: 0, - id: SessionId('seeded'), + id: SessionId('forked'), createdAt: 10, cwd: '/same', isSeeded: true, } - // Persisted before the cache was composed: the title lives only in its log. - withProjectionCache(ctx, { seeded: 'Unsafe body-free title' }) + const uncached = { ...forked, id: SessionId('uncached'), createdAt: 20 } + // The cache binds the fork's lifecycle by its header alone; the resolver + // needs no cut to read it. A session the cache never checkpointed keeps + // its title in its log only. + withProjectionCache(ctx, { forked: 'Forked title' }) vi.spyOn(ctx.sessionQuery, 'listSessions').mockResolvedValue([ - { header: seeded, live: false, persisted: true }, + { header: forked, live: false, persisted: true }, + { header: uncached, live: false, persisted: true }, ] as never) const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots') await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target))).resolves.toEqual([ - { sessionId: seeded.id, label: seeded.id, displayTitle: seeded.id, cwd: '/same', sameWorkspace: true, createdAt: 10 }, + { sessionId: forked.id, label: 'Forked title', displayTitle: 'Forked title', cwd: '/same', sameWorkspace: true, createdAt: 10 }, + { sessionId: uncached.id, label: uncached.id, displayTitle: uncached.id, cwd: '/same', sameWorkspace: true, createdAt: 20 }, + ]) + // The cached title finds the fork; the uncached session's own title cannot + // find it, and discovery still never opens a log. + await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'forked title')).resolves.toEqual([ + expect.objectContaining({ sessionId: forked.id }), ]) - // Its own title cannot find it, and discovery still never opens the log. await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'anything')).resolves.toEqual([]) expect(readTitles).not.toHaveBeenCalled() vi.restoreAllMocks() diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 78975d4a43..8256b0514d 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1878,16 +1878,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'The persisted projection cache service. Opens the `session_projcache` domain at init, checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus three mandatory points — session creation, `turn/end`, and session disposal (the live-to-cold moment) — and serves the cached rows for a session header. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write.', methods: [ { - signature: 'cachedSnapshot( meta: SessionHeader, inheritedEventCount: SessionLogOffset, keys?: readonly Extract[], ): ProjectionSnapshot | undefined', - description: 'The zero-I/O listing read: whole values viewed straight from the stored rows (version-matching keys only), each cut carried with its watermark so a client value store can seed under its higher-seq-wins rule — as stale as the last durable checkpoint but never wrong, and never from an unrelated log (the caller\'s header is the identity witness). Fresher paths (the history tail baseline) supersede these values whenever a session is actually opened.', - parameters: [{ name: 'meta', description: 'the listed session\'s header (identity witness; no log read).' }, { name: 'inheritedEventCount', description: 'exact inherited prefix length that completes the checkpoint identity.' }, { name: 'keys', description: 'optional projection keys required by the caller\'s audience.' }], - returns: 'the cut (`asOfSeq` = lowest served-row watermark), or `undefined` when no usable row exists for this lifecycle.', + signature: 'cachedSnapshot( meta: SessionHeader, keys?: readonly Extract[], ): ProjectionSnapshot | undefined', + description: 'The zero-I/O listing read: whole values viewed straight from the stored rows (version-matching keys only) of the record bound to the caller\'s lifecycle. The header is the only identity witness a listing holds, so this face matches the lifecycle identity (`formatVersion`, `createdAt`, `cwd`, `isSeeded`) and not the inherited cut: within one format generation the cut is fixed at fork time, so it distinguishes no lifecycle the other fields do not, and a viewed value never seeds a fold. The view is as stale as the last durable checkpoint but never wrong and never from an unrelated log. It is served as a cached block (`asOfSeq: -1`): the header cannot vouch that the stored row sequence is comparable with the caller\'s log, so the block claims no watermark and any sequenced value (a history baseline, a control baseline, a frame) supersedes it once the session is opened.', + parameters: [{ name: 'meta', description: 'the listed session\'s header (identity witness; no log read).' }, { name: 'keys', description: 'optional projection keys required by the caller\'s audience.' }], + returns: 'the cached block, or `undefined` when no usable row exists for this lifecycle at the current Session format.', }, { - signature: 'cachedPredecessorTitle( meta: SessionHeader, inheritedEventCount: SessionLogOffset, ): ProjectionSnapshot | undefined', - description: 'Read only a predecessor checkpoint\'s title as a zero-I/O listing hint.\n\nThe authoritative Session header supplies the lifecycle identity. A cache checkpoint can lag that log but cannot lead it because writes flush the log first, so a matching predecessor title is a genuine (possibly stale) fact from this Session. The registry still requires the current title projection\'s row version and schema. No other predecessor projection is exposed: format normalization can change their current meaning, and the strict cachedSnapshot / hydration paths continue to reject them.', - parameters: [{ name: 'meta', description: 'authoritative listed Session header.' }, { name: 'inheritedEventCount', description: 'exact inherited cut completing the lifecycle identity.' }], - returns: 'a title-only checkpoint view with `asOfSeq: -1`, or `undefined` when the record is current, newer, unrelated, missing, or incompatible with the title unit. The sentinel avoids reusing a sequence that a cardinality-changing Session migration may have remapped.', + signature: 'cachedPredecessorTitle(meta: SessionHeader): ProjectionSnapshot | undefined', + description: 'Read only a predecessor checkpoint\'s title as a zero-I/O listing hint.\n\nThe authoritative Session header supplies the lifecycle identity. A cache checkpoint can lag that log but cannot lead it because writes flush the log first, so a matching predecessor title is a genuine (possibly stale) fact from this Session. The registry still requires the current title projection\'s row version and schema. No other predecessor projection is exposed: format normalization can change their current meaning, and the cachedSnapshot / hydration paths continue to reject them.', + parameters: [{ name: 'meta', description: 'authoritative listed Session header.' }], + returns: 'a title-only cached block (`asOfSeq: -1`), or `undefined` when the record is current, newer, unrelated, missing, or incompatible with the title unit.', }, { signature: 'hydratePrepared( session: Session, events: readonly SessionEvent[], ): ProjectionSnapshot', diff --git a/packages/session/session-projection-cache/README.i18n.yaml b/packages/session/session-projection-cache/README.i18n.yaml index 9945e2d6dd..c921081f57 100644 --- a/packages/session/session-projection-cache/README.i18n.yaml +++ b/packages/session/session-projection-cache/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/session/session-projection-cache/README.md -README.md: 0417601210b697983993f770688a4e944d518790 -README.zh.md: 32dcbd93c8f18e504ca3a350586860b2e5cbd5b6 +README.md: af319d2606ca7faa7ba88a1da224b703a4a36943 +README.zh.md: 29568b481e61fc359b1f1e970f14d827e8facb50 diff --git a/packages/session/session-projection-cache/README.md b/packages/session/session-projection-cache/README.md index 0417601210..af319d2606 100644 --- a/packages/session/session-projection-cache/README.md +++ b/packages/session/session-projection-cache/README.md @@ -58,11 +58,11 @@ Three mandatory points always write: session creation persists the seed-derived ### Reading cached values -`cachedSnapshot(meta, inheritedEventCount)` synchronously serves client values from the storage domain's in-memory tables with zero I/O. It accepts only an identity-matching record and version- and schema-matching keys, then returns a `{ asOfSeq, values }` cut at the lowest served-row watermark. `cachedPredecessorTitle(meta, inheritedEventCount)` is the narrower listing-only exception: a structurally admitted predecessor record whose lifecycle matches may expose only a current-version-compatible `title` row. The title is a possibly stale fact from a durable prefix, not a fold seed; it carries the sentinel `asOfSeq: -1` because a cardinality-changing Session migration can invalidate the predecessor row's numeric sequence. All other predecessor rows remain unavailable. An unseeded listing knows that its cut is zero; a seeded header-only listing does not know the numeric cut and skips both fast paths until an authoritative body read supplies it. `coldSnapshot(meta, inheritedEventCount, events)` accepts the exact cut with a complete ordered log, skips the checkpointed prefix while folding, and refreshes the record without reading persistence itself. +`cachedSnapshot(meta, keys?)` is the read-only face: it synchronously serves client values from the storage domain's in-memory tables with zero I/O. It accepts a record whose lifecycle identity (`formatVersion`, `createdAt`, `cwd`, `isSeeded`) matches the header and serves its version- and schema-matching keys as a cached block `{ asOfSeq: -1, values }`. A header witnesses no inherited cut and cannot vouch that the stored row sequence is comparable with the log a consumer later opens, so the block claims no watermark and every value the connected Session later produces supersedes it. Within one format generation the cut is fixed at fork time and distinguishes no lifecycle the other fields do not, and a viewed value never seeds a fold, so seeded (forked) sessions are served exactly like unseeded ones. `cachedPredecessorTitle(meta)` is the narrower listing-only exception across a Session-format edge: a structurally admitted predecessor record whose lifecycle matches may expose only a current-version-compatible `title` row, because title text is invariant across the adjacent edges. All other predecessor rows remain unavailable. `coldSnapshot(meta, inheritedEventCount, events)` is a fold face: it accepts the exact cut with a complete ordered log, skips the checkpointed prefix while folding, and refreshes the record without reading persistence itself. ### What the cache guarantees -The log leads and the cache follows: a live checkpoint flushes the session's buffered events durably before the cache row lands, so a crash can leave the cache behind the log but never ahead of it. Reads and writes share the storage domain's coherent in-memory state; the per-unit write chain mutates memory only after durability. Each version-stamped record must match the live unit schema and complete lifecycle identity (`formatVersion`, `createdAt`, `cwd`, `isSeeded`, and `inheritedEventCount`), so a row folded from another Session format generation or fork cut cannot seed the caller. The JSON backend stores each record at `/session_projcache/sessions/.json` in an owner-only directory tree. Domain read validation preserves every own JSON key in checkpoint values, including `__proto__` and `constructor` inside opaque metadata. It rejects values that cannot survive a lossless JSON round trip, using the same rules as checkpoint writes. Each projection then validates its hydration state with its own `stateSchema`; fields carrying opaque JSON need a validator that preserves their keys. +The log leads and the cache follows: a live checkpoint flushes the session's buffered events durably before the cache row lands, so a crash can leave the cache behind the log but never ahead of it. Reads and writes share the storage domain's coherent in-memory state; the per-unit write chain mutates memory only after durability. Each version-stamped record must match the live unit schema and the lifecycle identity (`formatVersion`, `createdAt`, `cwd`, `isSeeded`); the fold faces (`hydratePrepared`, `coldSnapshot`, and checkpoint writes) also require the exact `inheritedEventCount`, so a row folded from another Session format generation or fork cut cannot seed the caller. The JSON backend stores each record at `/session_projcache/sessions/.json` in an owner-only directory tree. Domain read validation preserves every own JSON key in checkpoint values, including `__proto__` and `constructor` inside opaque metadata. It rejects values that cannot survive a lossless JSON round trip, using the same rules as checkpoint writes. Each projection then validates its hydration state with its own `stateSchema`; fields carrying opaque JSON need a validator that preserves their keys. Upgrades never block startup or expose an unproven fold. Records stamped with a version in the spec's `compatibleVersions` remain structurally readable for a current checkpoint rewrite, but a missing or older `formatVersion` never matches a current Session and therefore cannot seed hydration. A lifecycle-matching predecessor title remains available only through the listing hint above because title text is invariant across the adjacent Session-format edges and its row still passes the current projection `stateVersion` and schema. Once the format matches, absent lineage fields decode as the unseeded lineage — exact for unseeded sessions, while a seeded caller fails the identity match and refolds cold. A stored record that still fails schema validation is moved aside as `.json.bak.` under the domain's `invalidRecords: 'backup-and-skip'` policy, logged with its cause, and rebuilt by the next checkpoint. diff --git a/packages/session/session-projection-cache/README.zh.md b/packages/session/session-projection-cache/README.zh.md index 32dcbd93c8..29568b481e 100644 --- a/packages/session/session-projection-cache/README.zh.md +++ b/packages/session/session-projection-cache/README.zh.md @@ -58,11 +58,11 @@ kind: "package-reference" ### 读取缓存值 -`cachedSnapshot(meta, inheritedEventCount)` 以零 I/O 从存储域的内存表同步提供客户端值。它只接受身份匹配的记录以及版本和 schema 均匹配的 key,再按所服务行的最低水位返回 `{ asOfSeq, values }` 切面。`cachedPredecessorTitle(meta, inheritedEventCount)` 是更窄的列表专用例外:生命周期匹配且已通过结构准入的 predecessor record 只能公开与当前版本兼容的 `title` row。该 title 是 durable prefix 中可能过时的事实,而不是 fold seed;它携带 sentinel `asOfSeq: -1`,因为改变事件数量的 Session 迁移会使 predecessor row 的数字序号失效。其他 predecessor row 仍不可用。未 seeded 的列表知道切点为零;仅 header 的 seeded 列表不知道数字切点,因此两条快速路径都要跳过,直到权威正文读取提供它。`coldSnapshot(meta, inheritedEventCount, events)` 接受精确切点与完整有序日志,在折叠时跳过已检查点化的前缀,并在自身不读取持久化层的情况下刷新记录。 +`cachedSnapshot(meta, keys?)` 是只读面:以零 I/O 从存储域的内存表同步提供客户端值。它接受生命周期身份(`formatVersion`、`createdAt`、`cwd`、`isSeeded`)与 header 匹配的记录,把其中版本和 schema 均匹配的 key 作为 cached block `{ asOfSeq: -1, values }` 提供。header 作证不了 inherited cut,也作证不了存储行的序号与消费者稍后打开的日志可比,因此该 block 不声明水位,建连后的 Session 产出的任何值都会覆盖它。在同一格式代内,cut 在 fork 时写死,不能区分其他字段区分不了的生命周期,而只读视图也从不播种 fold,所以 seeded(fork 出来的)会话与 unseeded 会话被同样地提供。`cachedPredecessorTitle(meta)` 是跨 Session 格式 edge 的更窄列表专用例外:生命周期匹配且已通过结构准入的 predecessor record 只能公开与当前版本兼容的 `title` row,因为 title 文本在相邻 edge 之间保持不变。其他 predecessor row 仍不可用。`coldSnapshot(meta, inheritedEventCount, events)` 是 fold 面:接受精确切点与完整有序日志,在折叠时跳过已检查点化的前缀,并在自身不读取持久化层的情况下刷新记录。 ### 缓存保证什么 -日志领先,缓存跟随:活会话检查点先把会话的缓冲事件持久化,然后才保存缓存记录。因此崩溃可能让缓存落后于日志,但绝不会让缓存领先。读取和写入共享存储域内一致的内存状态;逐单元写入链只在持久化成功后修改内存。每个带版本戳的记录必须匹配当前运行单元的 schema 与完整生命周期身份(`formatVersion`、`createdAt`、`cwd`、`isSeeded` 和 `inheritedEventCount`),因此从另一会话格式代或 fork 切点折叠出的行不能播种调用方。JSON 后端把每条记录存于仅所有者可访问的 `/session_projcache/sessions/.json` 目录树中。Domain 读取校验保留检查点值中的所有自有 JSON 键,包括不透明元数据中的 `__proto__` 与 `constructor`。它拒绝无法无损完成 JSON 往返的值,并与检查点写入使用相同规则。随后,每个 projection 用自己的 `stateSchema` 校验 hydration 状态;承载不透明 JSON 的字段需要使用保留其键的校验器。 +日志领先,缓存跟随:活会话检查点先把会话的缓冲事件持久化,然后才保存缓存记录。因此崩溃可能让缓存落后于日志,但绝不会让缓存领先。读取和写入共享存储域内一致的内存状态;逐单元写入链只在持久化成功后修改内存。每个带版本戳的记录必须匹配当前运行单元的 schema 与生命周期身份(`formatVersion`、`createdAt`、`cwd`、`isSeeded`);fold 面(`hydratePrepared`、`coldSnapshot` 与检查点写入)还要求精确的 `inheritedEventCount`,因此从另一会话格式代或 fork 切点折叠出的行不能播种调用方。JSON 后端把每条记录存于仅所有者可访问的 `/session_projcache/sessions/.json` 目录树中。Domain 读取校验保留检查点值中的所有自有 JSON 键,包括不透明元数据中的 `__proto__` 与 `constructor`。它拒绝无法无损完成 JSON 往返的值,并与检查点写入使用相同规则。随后,每个 projection 用自己的 `stateSchema` 校验 hydration 状态;承载不透明 JSON 的字段需要使用保留其键的校验器。 升级绝不拖垮启动,也不会暴露未经证明的折叠结果。版本戳落在 spec `compatibleVersions` 集合内的记录仍可被结构化读取并等待当前检查点重写,但缺失或更旧的 `formatVersion` 绝不匹配当前 Session,因此不能作为 hydrate seed。生命周期匹配的 predecessor title 只能通过上述列表 hint 读取,因为 title 文本在相邻 Session format edge 之间保持不变,并且该 row 仍须通过当前 projection `stateVersion` 与 schema。格式匹配后,缺失的 lineage 字段解码为 unseeded lineage——对非 fork 会话精确无误,seeded 调用方则通不过身份比对、回落冷折叠。仍然通不过 schema 校验的存量记录会按域的 `invalidRecords: 'backup-and-skip'` 策略移出为 `.json.bak.<时间戳>`、连同原因写入日志,并由下一次检查点重建。 diff --git a/packages/session/session-projection-cache/src/index.ts b/packages/session/session-projection-cache/src/index.ts index 639dfcc978..66effbbc49 100644 --- a/packages/session/session-projection-cache/src/index.ts +++ b/packages/session/session-projection-cache/src/index.ts @@ -35,7 +35,19 @@ import type { KvTable } from '@deepseek-ai/dsh-storage-domain' import { projectionCacheDomainSpec } from './spec.ts' import type { CheckpointIdentity, CheckpointRecord } from './spec.ts' -/** Complete identity written by the current cache generation. */ +/** + * The identity a Session header alone witnesses: the format generation the + * fold must have run under and the fields that distinguish one lifecycle + * stored under a session id. The read-only listing face matches exactly this. + */ +type LifecycleIdentity = Omit + +/** + * Complete identity written by the current cache generation: the lifecycle + * identity plus the exact inherited cut, which only a caller holding the + * Session or its body knows. The fold face (hydration, checkpoint writes) + * matches this. + */ type CurrentCheckpointIdentity = CheckpointIdentity & { formatVersion: number isSeeded: boolean @@ -129,26 +141,30 @@ export class SessionProjectionCache extends Service { /** * The zero-I/O listing read: whole values viewed straight from the stored - * rows (version-matching keys only), each cut carried with its watermark so - * a client value store can seed under its higher-seq-wins rule — as stale - * as the last durable checkpoint but never wrong, and never from an - * unrelated log (the caller's header is the identity witness). Fresher - * paths (the history tail baseline) supersede these values whenever a - * session is actually opened. + * rows (version-matching keys only) of the record bound to the caller's + * lifecycle. The header is the only identity witness a listing holds, so + * this face matches the lifecycle identity (`formatVersion`, `createdAt`, + * `cwd`, `isSeeded`) and not the inherited cut: within one format + * generation the cut is fixed at fork time, so it distinguishes no + * lifecycle the other fields do not, and a viewed value never seeds a fold. + * The view is as stale as the last durable checkpoint but never wrong and + * never from an unrelated log. It is served as a cached block + * (`asOfSeq: -1`): the header cannot vouch that the stored row sequence is + * comparable with the caller's log, so the block claims no watermark and + * any sequenced value (a history baseline, a control baseline, a frame) + * supersedes it once the session is opened. * @param meta - the listed session's header (identity witness; no log read). - * @param inheritedEventCount - exact inherited prefix length that completes - * the checkpoint identity. * @param keys - optional projection keys required by the caller's audience. - * @returns the cut (`asOfSeq` = lowest served-row watermark), or - * `undefined` when no usable row exists for this lifecycle. + * @returns the cached block, or `undefined` when no usable row exists for + * this lifecycle at the current Session format. */ cachedSnapshot( meta: SessionHeader, - inheritedEventCount: SessionLogOffset, keys?: readonly Extract[], ): ProjectionSnapshot | undefined { - const record = this.recordFor(meta.id, identityOf(meta, inheritedEventCount)) - if (record === undefined) return undefined + const expected = lifecycleIdentityOf(meta) + const record = this.requireTable().get(meta.id) + if (record === undefined || !currentLifecycleMatches(record.identity, expected)) return undefined return this.viewRecord(record, keys) } @@ -161,43 +177,32 @@ export class SessionProjectionCache extends Service { * fact from this Session. The registry still requires the current title * projection's row version and schema. No other predecessor projection is * exposed: format normalization can change their current meaning, and the - * strict {@link cachedSnapshot} / hydration paths continue to reject them. + * {@link cachedSnapshot} / hydration paths continue to reject them. * @param meta - authoritative listed Session header. - * @param inheritedEventCount - exact inherited cut completing the lifecycle identity. - * @returns a title-only checkpoint view with `asOfSeq: -1`, or `undefined` - * when the record is current, newer, unrelated, missing, or incompatible - * with the title unit. The sentinel avoids reusing a sequence that a - * cardinality-changing Session migration may have remapped. + * @returns a title-only cached block (`asOfSeq: -1`), or `undefined` when + * the record is current, newer, unrelated, missing, or incompatible with + * the title unit. */ - cachedPredecessorTitle( - meta: SessionHeader, - inheritedEventCount: SessionLogOffset, - ): ProjectionSnapshot | undefined { - const expected = identityOf(meta, inheritedEventCount) + cachedPredecessorTitle(meta: SessionHeader): ProjectionSnapshot | undefined { + const expected = lifecycleIdentityOf(meta) const record = this.requireTable().get(meta.id) if (record === undefined || !predecessorIdentityMatches(record.identity, expected)) return undefined - const title = this.viewRecord(record, [PREDECESSOR_TITLE_KEY]) - return title === undefined ? undefined : { ...title, asOfSeq: -1 } + return this.viewRecord(record, [PREDECESSOR_TITLE_KEY]) } - /** View selected wire rows and bind them to their lowest served watermark. */ + /** + * View selected wire rows as a cached block. The block claims no watermark: + * a listing cannot prove the stored row sequence is comparable with the + * log a consumer later opens, and under-claiming is safe where + * over-claiming would let a stale value outrank a sequenced one. + */ private viewRecord( record: CheckpointRecord, keys?: readonly Extract[], ): ProjectionSnapshot | undefined { const values = this.ctx.sessionProjections.viewCheckpoint(record.rows, keys) - const servedKeys = Object.keys(values) - if (servedKeys.length === 0) return undefined - // The block carries ONE cut: the lowest served watermark is the seq every - // value is at least current as of (under-claiming is safe under - // higher-seq-wins; over-claiming would let a stale value outrank pushes). - const firstKey = servedKeys[0] as string - let asOfSeq = (record.rows[firstKey] as ProjectionCheckpoint[string]).seq - for (const key of servedKeys.slice(1)) { - const row = record.rows[key] as ProjectionCheckpoint[string] - if (row.seq < asOfSeq) asOfSeq = row.seq - } - return { asOfSeq, values } + if (Object.keys(values).length === 0) return undefined + return { asOfSeq: -1, values } } /** @@ -391,7 +396,17 @@ export class SessionProjectionCache extends Service { } } -/** Project a header onto the identity fields a record is bound to. */ +/** Project a header onto the identity fields a header alone can witness. */ +function lifecycleIdentityOf(header: SessionHeader): LifecycleIdentity { + return { + formatVersion: header.version, + createdAt: header.createdAt, + ...header.cwd === undefined ? {} : { cwd: header.cwd }, + isSeeded: header.isSeeded, + } +} + +/** Project a header and its exact inherited cut onto the complete fold identity. */ function identityOf( header: SessionHeader, inheritedEventCount: SessionLogOffset, @@ -400,23 +415,30 @@ function identityOf( if (!header.isSeeded && cut !== 0) { throw new Error('unseeded projection-cache identity inherited event count must be 0') } - return { - formatVersion: header.version, - createdAt: header.createdAt, - ...header.cwd === undefined ? {} : { cwd: header.cwd }, - isSeeded: header.isSeeded, - inheritedEventCount: cut, - } + return { ...lifecycleIdentityOf(header), inheritedEventCount: cut } } /** - * Whether a stored record's bound identity names the caller's lifecycle. - * An absent format generation cannot prove the fold semantics and never - * matches. Once the format matches, absent lineage fields (records admitted - * via `compatibleVersions` predate them) read as the unseeded lineage: exact - * for an unseeded caller, while a seeded caller fails the match. + * Whether a stored record may seed the caller's fold: the current format + * generation, the same lifecycle, and the same inherited cut. A record folded + * under another cut encodes that cut in unit states (`schedule`, + * `subagentCatalog`, `permissions`) and would carry it into the continued + * fold and the next checkpoint. Absent lineage fields (records admitted via + * `compatibleVersions` predate them) read as the unseeded lineage: exact for + * an unseeded caller, while a seeded caller fails the match. */ function identityMatches(stored: CheckpointIdentity, expected: CurrentCheckpointIdentity): boolean { + return currentLifecycleMatches(stored, expected) + && (stored.inheritedEventCount ?? 0) === expected.inheritedEventCount +} + +/** + * Whether a stored record was folded from the caller's lifecycle at the + * current Session format. An absent format generation cannot prove the fold + * semantics and never matches. This is the whole identity a header-only + * reader can check, and the whole identity a view needs. + */ +function currentLifecycleMatches(stored: CheckpointIdentity, expected: LifecycleIdentity): boolean { return stored.formatVersion === expected.formatVersion && lifecycleIdentityMatches(stored, expected) } @@ -424,7 +446,7 @@ function identityMatches(stored: CheckpointIdentity, expected: CurrentCheckpoint /** Match one predecessor cache record to the authoritative listed lifecycle. */ function predecessorIdentityMatches( stored: CheckpointIdentity, - expected: CurrentCheckpointIdentity, + expected: LifecycleIdentity, ): boolean { const predecessor = stored.formatVersion === undefined || stored.formatVersion < expected.formatVersion @@ -434,12 +456,11 @@ function predecessorIdentityMatches( /** Match the format-independent fields that distinguish one Session lifecycle. */ function lifecycleIdentityMatches( stored: CheckpointIdentity, - expected: CurrentCheckpointIdentity, + expected: LifecycleIdentity, ): boolean { return stored.createdAt === expected.createdAt && stored.cwd === expected.cwd && (stored.isSeeded ?? false) === expected.isSeeded - && (stored.inheritedEventCount ?? 0) === expected.inheritedEventCount } export default SessionProjectionCache diff --git a/packages/session/session-projection-cache/tests/cache.spec.ts b/packages/session/session-projection-cache/tests/cache.spec.ts index af79b02947..e66f2c2e60 100644 --- a/packages/session/session-projection-cache/tests/cache.spec.ts +++ b/packages/session/session-projection-cache/tests/cache.spec.ts @@ -45,11 +45,13 @@ declare module '@deepseek-ai/dsh-session-projection/types' { 'cache-test/count': number 'cache-test/secret': string 'cache-test/marks3': MarksState + title: string | null } interface SessionProjectionMap { 'cache-test/marks': { marks: string[] } 'cache-test/secondary-marks': { marks: string[] } 'cache-test/marks3': { marks: string[] } + title: string | null } } @@ -108,6 +110,16 @@ const secondaryMarksUnit = { stateVersion: 1, } satisfies ProjectionDefinition<'cache-test/secondary-marks', MarksState> +/** Mirrors the shipped title unit's storage face (stateVersion 1, bare-string state). */ +const titleUnit = { + key: 'title', + stateSchema: z.string().nullable(), + init: () => null, + apply: state => state, + wire: { viewSchema: z.string().nullable(), view: state => state }, + stateVersion: 1, +} satisfies ProjectionDefinition<'title', string | null> + /** One session's record document on the per-record medium. */ const recordPath = (root: string, id: Session['id']): string => join(root, projectionCacheDomainSpec.name, 'sessions', `${String(id)}.json`) @@ -353,16 +365,17 @@ describe('SessionProjectionCache write policy', () => { }) describe('SessionProjectionCache listing read', () => { - it('rejects a nonzero inherited cut for an unseeded header', async () => { + it('rejects a nonzero inherited cut for an unseeded header on the fold face', async () => { const { cache } = await harness() - expect(() => cache.cachedSnapshot( + expect(() => cache.coldSnapshot( headerOf(SessionId('invalid-unseeded-cut')), SessionLogOffset(1), + [], )).toThrow('unseeded projection-cache identity inherited event count must be 0') }) - it('uses the lowest watermark across every served wire row', async () => { + it('serves rows with differing watermarks as one cached block that claims no watermark', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-')) roots.push(root) await seedRecord(root, 'watermark-lower', { @@ -376,13 +389,18 @@ describe('SessionProjectionCache listing read', () => { const { ctx, cache } = await harness({ root }) ctx.sessionProjections.register(secondaryMarksUnit) - expect(cache.cachedSnapshot(headerOf(SessionId('watermark-lower')), SessionLogOffset(0))?.asOfSeq) - .toBe(2) - expect(cache.cachedSnapshot(headerOf(SessionId('watermark-higher')), SessionLogOffset(0))?.asOfSeq) - .toBe(4) + for (const id of ['watermark-lower', 'watermark-higher']) { + expect(cache.cachedSnapshot(headerOf(SessionId(id)))).toEqual({ + asOfSeq: -1, + values: { + 'cache-test/marks': { marks: ['primary'] }, + 'cache-test/secondary-marks': { marks: ['secondary'] }, + }, + }) + } }) - it('refuses a checkpoint created for a different inherited cut', async () => { + it('serves a seeded lifecycle by header alone while the fold face still refuses another cut', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-')) roots.push(root) const id = SessionId('cut-identity') @@ -399,12 +417,54 @@ describe('SessionProjectionCache listing read', () => { ) const { cache } = await harness({ root }) const seededHeader = { ...headerOf(id), isSeeded: true } + const log: SessionEvent[] = [ + { type: 'turn/start', seq: SessionSeq(0), time: 0, data: { turn: 1 } }, + { type: 'cache-test/mark', seq: SessionSeq(1), time: 1, data: { marks: ['logged'] } }, + ] - expect(cache.cachedSnapshot(seededHeader, SessionLogOffset(2))?.values['cache-test/marks']) + // The listing knows only the header: the record's lifecycle matches, so + // its rows are viewed without any cut. + expect(cache.cachedSnapshot(seededHeader)) + .toEqual({ asOfSeq: -1, values: { 'cache-test/marks': { marks: ['seed'] } } }) + // An unseeded header names another lifecycle even though the id matches. + expect(cache.cachedSnapshot(headerOf(id))).toBeUndefined() + // The fold face holds the exact cut: the matching cut seeds from the row, + // another cut refolds the whole log instead of continuing a foreign state. + expect(cache.coldSnapshot(seededHeader, SessionLogOffset(2), log).values['cache-test/marks']) .toEqual({ marks: ['seed'] }) - expect(cache.cachedSnapshot(seededHeader, SessionLogOffset(1))).toBeUndefined() - expect(() => cache.cachedSnapshot(headerOf(id), SessionLogOffset(1))) - .toThrow('unseeded projection-cache identity inherited event count must be 0') + expect(cache.coldSnapshot(seededHeader, SessionLogOffset(1), log).values['cache-test/marks']) + .toEqual({ marks: ['logged'] }) + }) + + it('serves a seeded lifecycle\'s predecessor title by header alone', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-')) + roots.push(root) + const id = SessionId('seeded-predecessor') + await seedRecord( + root, + id, + { + 'cache-test/marks': { ver: 1, seq: SessionSeq(3), val: { marks: ['fork'] } }, + title: { ver: 1, seq: SessionSeq(3), val: 'forked title' }, + }, + { + formatVersion: SESSION_FORMAT_VERSION - 1, + createdAt: 0, + isSeeded: true, + inheritedEventCount: SessionLogOffset(5), + }, + ) + const { ctx, cache } = await harness({ root }) + ctx.sessionProjections.register(titleUnit) + const seededHeader = { ...headerOf(id), isSeeded: true } + + // An older format generation never serves the full block, but its title + // survives the format edge, for a seeded lifecycle as for an unseeded one. + expect(cache.cachedSnapshot(seededHeader)).toBeUndefined() + expect(cache.cachedPredecessorTitle(seededHeader)) + .toEqual({ asOfSeq: -1, values: { title: 'forked title' } }) + // An unseeded header names another lifecycle: no title either. + expect(cache.cachedPredecessorTitle(headerOf(id))).toBeUndefined() }) it('serves a creation-time checkpoint at the before-first-event cursor', async () => { @@ -415,7 +475,7 @@ describe('SessionProjectionCache listing read', () => { }) const { cache } = await harness({ root }) - expect(cache.cachedSnapshot(headerOf(SessionId('before-first-event')), SessionLogOffset(0))) + expect(cache.cachedSnapshot(headerOf(SessionId('before-first-event')))) .toEqual({ asOfSeq: -1, values: { 'cache-test/marks': { marks: [] } } }) }) @@ -436,7 +496,7 @@ describe('SessionProjectionCache listing read', () => { ) const { cache } = await harness({ root }) - expect(cache.cachedSnapshot(headerOf(id), SessionLogOffset(0))).toBeUndefined() + expect(cache.cachedSnapshot(headerOf(id))).toBeUndefined() }) it('keeps host-only checkpoint state out of cached wire snapshots', async () => { @@ -450,15 +510,15 @@ describe('SessionProjectionCache listing read', () => { ctx.sessionProjections.register(secretUnit) const header = headerOf(SessionId('host-state')) - expect(cache.cachedSnapshot(header, SessionLogOffset(0))).toEqual({ - asOfSeq: 4, + expect(cache.cachedSnapshot(header)).toEqual({ + asOfSeq: -1, values: { 'cache-test/marks': { marks: ['wire'] } }, }) - expect(JSON.stringify(cache.cachedSnapshot(header, SessionLogOffset(0)))) + expect(JSON.stringify(cache.cachedSnapshot(header))) .not.toContain('private prompt text') }) - it('serves identity-matching rows with the cut watermark and refuses unrelated ones', async () => { + it('serves lifecycle-matching rows as a cached block and refuses unrelated ones', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-')) roots.push(root) await seedRecord(root, 'listed', { @@ -466,34 +526,31 @@ describe('SessionProjectionCache listing read', () => { }) const { cache } = await harness({ root }) const id = SessionId('listed') - // Matching header: values plus the watermark the client seeds under. - expect(cache.cachedSnapshot(headerOf(id), SessionLogOffset(0))) - .toEqual({ asOfSeq: 4, values: { 'cache-test/marks': { marks: ['t'] } } }) + // Matching header: the values, claiming no watermark the client could compare. + expect(cache.cachedSnapshot(headerOf(id))) + .toEqual({ asOfSeq: -1, values: { 'cache-test/marks': { marks: ['t'] } } }) // A recreated id (different createdAt): the record is unrelated — no block. - expect(cache.cachedSnapshot(headerOf(id, 777), SessionLogOffset(0))).toBeUndefined() + expect(cache.cachedSnapshot(headerOf(id, 777))).toBeUndefined() // Unknown id: no block. - expect(cache.cachedSnapshot(headerOf(SessionId('never-cached')), SessionLogOffset(0))) + expect(cache.cachedSnapshot(headerOf(SessionId('never-cached')))) .toBeUndefined() }) - it('carries ONE cut across multiple served rows: the lowest watermark wins', async () => { + it('views every served wire row of one record in one cached block', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-')) roots.push(root) - // Equal watermarks: whichever row is visited second cannot lower the cut, - // so the one-cut fold sees both a lowering and a non-lowering row in - // every iteration order. await seedRecord(root, 'multi-row', { 'cache-test/marks': { ver: 1, seq: SessionSeq(4), val: { marks: ['a'] } }, 'cache-test/marks3': { ver: 1, seq: SessionSeq(4), val: { marks: ['b'] } }, }) const { ctx, cache } = await harness({ root }) ctx.sessionProjections.register(marks3Unit) - const block = cache.cachedSnapshot(headerOf(SessionId('multi-row')), SessionLogOffset(0)) + const block = cache.cachedSnapshot(headerOf(SessionId('multi-row'))) expect(block?.values).toEqual({ 'cache-test/marks': { marks: ['a'] }, 'cache-test/marks3': { marks: ['b'] }, }) - expect(block?.asOfSeq).toBe(4) + expect(block?.asOfSeq).toBe(-1) }) it('returns undefined when the stored record version is not accepted', async () => { @@ -516,7 +573,7 @@ describe('SessionProjectionCache listing read', () => { }, })) const { cache } = await harness({ root }) - expect(cache.cachedSnapshot(headerOf(SessionId('all-stale')), SessionLogOffset(0))) + expect(cache.cachedSnapshot(headerOf(SessionId('all-stale')))) .toBeUndefined() }) @@ -537,11 +594,20 @@ describe('SessionProjectionCache listing read', () => { const { cache } = await harness({ root }) const id = SessionId('pre-lineage') // Unseeded caller: the absent lineage is exactly its identity — served. - expect(cache.cachedSnapshot(headerOf(id), SessionLogOffset(0))) - .toEqual({ asOfSeq: 4, values: { 'cache-test/marks': { marks: ['kept'] } } }) - // Seeded caller: the lineage-less record cannot vouch for the cut — refused. - expect(cache.cachedSnapshot({ ...headerOf(id), isSeeded: true }, SessionLogOffset(2))) + expect(cache.cachedSnapshot(headerOf(id))) + .toEqual({ asOfSeq: -1, values: { 'cache-test/marks': { marks: ['kept'] } } }) + // Seeded caller: the lineage-less record cannot vouch for a seeded lifecycle — refused. + expect(cache.cachedSnapshot({ ...headerOf(id), isSeeded: true })) .toBeUndefined() + // The fold face reads the absent lineage as the unseeded cut 0: the + // unseeded caller continues from the row, the seeded caller refolds. + const log: SessionEvent[] = [0, 1, 2, 3, 4].map(seq => ({ + type: 'cache-test/mark', seq: SessionSeq(seq), time: seq, data: { marks: [`m${seq}`] }, + })) + expect(cache.coldSnapshot(headerOf(id), SessionLogOffset(0), log).values['cache-test/marks']) + .toEqual({ marks: ['kept'] }) + expect(cache.coldSnapshot({ ...headerOf(id), isSeeded: true }, SessionLogOffset(1), log).values['cache-test/marks']) + .toEqual({ marks: ['m4'] }) }) it('refuses an accepted predecessor record without a Session format generation', async () => { @@ -559,7 +625,7 @@ describe('SessionProjectionCache listing read', () => { })) const { cache } = await harness({ root }) - expect(cache.cachedSnapshot(headerOf(id), SessionLogOffset(0))).toBeUndefined() + expect(cache.cachedSnapshot(headerOf(id))).toBeUndefined() }) it('returns undefined when every stored row is version-mismatched', async () => { @@ -571,7 +637,7 @@ describe('SessionProjectionCache listing read', () => { 'cache-test/marks': { ver: 99, seq: SessionSeq(4), val: { marks: ['old'] } }, }) const { cache } = await harness({ root }) - expect(cache.cachedSnapshot(headerOf(SessionId('row-stale')), SessionLogOffset(0))) + expect(cache.cachedSnapshot(headerOf(SessionId('row-stale')))) .toBeUndefined() }) @@ -589,10 +655,10 @@ describe('SessionProjectionCache listing read', () => { }) const { cache } = await harness({ root }) const id = SessionId('homed') - expect(cache.cachedSnapshot(headerOf(id, 0, '/work'), SessionLogOffset(0))?.values['cache-test/marks']) + expect(cache.cachedSnapshot(headerOf(id, 0, '/work'))?.values['cache-test/marks']) .toEqual({ marks: ['w'] }) - expect(cache.cachedSnapshot(headerOf(id, 0, '/elsewhere'), SessionLogOffset(0))).toBeUndefined() - expect(cache.cachedSnapshot(headerOf(id, 0), SessionLogOffset(0))).toBeUndefined() + expect(cache.cachedSnapshot(headerOf(id, 0, '/elsewhere'))).toBeUndefined() + expect(cache.cachedSnapshot(headerOf(id, 0))).toBeUndefined() }) it('returns undefined for a malformed record document (refold from the log on the caller side)', async () => { @@ -602,7 +668,7 @@ describe('SessionProjectionCache listing read', () => { await mkdir(dirname(path), { recursive: true }) await writeFile(path, 'not json at all') const { cache } = await harness({ root }) - expect(cache.cachedSnapshot(headerOf(SessionId('malformed')), SessionLogOffset(0))) + expect(cache.cachedSnapshot(headerOf(SessionId('malformed')))) .toBeUndefined() }) }) diff --git a/packages/session/session-projection-cache/tests/fixtures.spec.ts b/packages/session/session-projection-cache/tests/fixtures.spec.ts index 3b3de927d6..1116aebb66 100644 --- a/packages/session/session-projection-cache/tests/fixtures.spec.ts +++ b/packages/session/session-projection-cache/tests/fixtures.spec.ts @@ -20,7 +20,7 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { Context } from '@deepseek-ai/cordis' import { z } from 'zod' -import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { SessionHeader } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' @@ -209,12 +209,10 @@ describe('archived version recovery', () => { const { ctx, cache } = await harness(root) expect(cache.cachedSnapshot( headerFor(SessionId(sid), record.identity), - SessionLogOffset(0), ['title'], )).toBeUndefined() expect(cache.cachedPredecessorTitle( headerFor(SessionId(sid), record.identity), - SessionLogOffset(0), )).toEqual({ asOfSeq: -1, values: { title: record.rows.title?.val }, @@ -243,12 +241,10 @@ describe('archived version recovery', () => { const { ctx, cache } = await harness(root) expect(cache.cachedSnapshot( headerFor(id, doc.record.identity), - SessionLogOffset(0), ['title'], )).toBeUndefined() expect(cache.cachedPredecessorTitle( headerFor(id, doc.record.identity), - SessionLogOffset(0), )).toEqual({ asOfSeq: -1, values: { title: doc.record.rows.title?.val }, @@ -290,25 +286,25 @@ describe('archived version recovery', () => { cwd: '/work', isSeeded: false, }) - expect(cache.cachedPredecessorTitle(listed('older'), SessionLogOffset(0))).toEqual({ + expect(cache.cachedPredecessorTitle(listed('older'))).toEqual({ asOfSeq: -1, values: { title: 'older title' }, }) - expect(cache.cachedPredecessorTitle(listed('current'), SessionLogOffset(0))).toBeUndefined() - expect(cache.cachedPredecessorTitle(listed('newer'), SessionLogOffset(0))).toBeUndefined() - expect(cache.cachedPredecessorTitle(listed('stale-title'), SessionLogOffset(0))).toBeUndefined() - expect(cache.cachedPredecessorTitle(listed('missing'), SessionLogOffset(0))).toBeUndefined() + expect(cache.cachedPredecessorTitle(listed('current'))).toBeUndefined() + expect(cache.cachedPredecessorTitle(listed('newer'))).toBeUndefined() + expect(cache.cachedPredecessorTitle(listed('stale-title'))).toBeUndefined() + expect(cache.cachedPredecessorTitle(listed('missing'))).toBeUndefined() }) - it('refuses a lineage-less archive for a seeded caller (identity mismatch, cold rebuild)', async () => { + it('refuses a lineage-less archive for a seeded caller (lifecycle mismatch, cold rebuild)', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-fx-')) const id = SessionId('fixture-seeded') const doc = await placeDoc(root, id, 'v5-lineageless-doc.json') const { cache } = await harness(root) const seeded = { ...headerFor(id, doc.record.identity), isSeeded: true } - expect(cache.cachedSnapshot(seeded, SessionLogOffset(2), ['title'])).toBeUndefined() - expect(cache.cachedPredecessorTitle(seeded, SessionLogOffset(2))).toBeUndefined() + expect(cache.cachedSnapshot(seeded, ['title'])).toBeUndefined() + expect(cache.cachedPredecessorTitle(seeded)).toBeUndefined() }) it('backs up and skips a record that fails schema validation instead of failing the boot', async () => { @@ -350,11 +346,10 @@ describe('archived version recovery', () => { // The broken record reads as absent; its predecessor-stamped neighbor // remains available for a safe current rewrite. const cache = ctx.sessionProjectionCache - expect(cache.cachedSnapshot(headerFor(SessionId('broken'), { createdAt: 0 }), SessionLogOffset(0))) + expect(cache.cachedSnapshot(headerFor(SessionId('broken'), { createdAt: 0 }))) .toBeUndefined() expect(cache.cachedSnapshot( headerFor(SessionId('survivor'), good.record.identity), - SessionLogOffset(0), ['title'], )).toBeUndefined() await assertRewrite(ctx, root, SessionId('survivor')) diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts index 9de66e771c..c52d9f3bcc 100644 --- a/packages/subagent/subagent/src/list-children.ts +++ b/packages/subagent/subagent/src/list-children.ts @@ -9,7 +9,7 @@ */ import type { Context } from '@deepseek-ai/cordis' -import { type Session, SessionLogOffset } from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection' import type { SessionProjectionCache } from '@deepseek-ai/dsh-session-projection-cache' @@ -297,13 +297,13 @@ async function resolveColdIdentity( ): Promise { const childId = header.id // A header deliberately exposes only whether a fork cut exists, not its - // integer. An unseeded lifecycle has the exact cut 0 and may use the cache; - // a seeded lifecycle must read the body before an identity seq can be - // classified as inherited or owned. + // integer. An unseeded lifecycle has the exact cut 0, so its cached + // descriptor is owned at every valid seq; a seeded lifecycle must read the + // body before an identity seq can be classified as inherited or owned. if (cache !== undefined && !header.isSeeded) { let cached: SubagentIdentityProjection | null | undefined try { - cached = cache.cachedSnapshot(header, SessionLogOffset(0), ['subagent'])?.values.subagent + cached = cache.cachedSnapshot(header, ['subagent'])?.values.subagent } catch { // Unlike the preparation fold below, a throwing cache read renders no // verdict: the cache is derived data, so its damage (a poisoned stored