fix(session): reconcile cached projection hints

This commit is contained in:
pku-xht 2026-08-25 22:17:55 +08:00
parent abebdb1eaa
commit 5fe7dc333f
20 changed files with 216 additions and 140 deletions

View file

@ -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 .agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md
2026-08-25-session-observations-and-projection-owned-client-state.md: e47f2fc75ecbca51d01af077f6c6ab98f4e275f9
2026-08-25-session-observations-and-projection-owned-client-state.zh.md: 527a4eb6b6765cba95d6067f2be60bff8f31a559
2026-08-25-session-observations-and-projection-owned-client-state.md: 0fa69dde28eadc860d426ea511f4aaf1356c7afa
2026-08-25-session-observations-and-projection-owned-client-state.zh.md: ab601d7e6839eba6370564a25f92aa5cef99ca40

View file

@ -108,11 +108,11 @@ These distinctions prevent one overloaded `undefined` from representing cache mi
| Follow opening baseline | Complete for the Host composition | Exact opening cursor | Capability absent |
| Projection frame | One whole key | Event sequence carried by the frame | Not applicable |
The Client stores one row per key with its sequence number. A newer hint, baseline, or frame replaces a row; an equal or older input is ignored. Reconnect can therefore replace the event window without rolling back a projection frame that was already accepted at a later sequence.
The Client stores one row per key with its provenance and sequence number. A list hint fills or advances only a tentative row. A complete opening baseline replaces or clears tentative rows even when a cache hint claims a higher sequence, while preserving an authoritative frame newer than the opening cut. Frames use higher-sequence-wins and promote an equal-sequence hint to authoritative state. A replacement control baseline first discards rows beyond its durable cut, then installs its complete values.
The list view reads the same per-Session store as the opened Session. Hints can populate title, preset, and other list presentation before follow completes; the opening baseline then converges that state without creating a second summary-only authority.
The per-Session Client projection store accepts list hints, the follow baseline, and later whole-value frames under one higher-sequence-wins rule. It never folds Session events. A baseline or frame may advance a hinted value, while an older cut cannot overwrite a newer row.
The per-Session Client projection store never folds Session events; it only reconciles finished hints, complete baselines, and whole-value frames under those source-aware rules.
Data that is not derived from one Session remains outside projections. `llm.models` owns the Host-generation model catalog, and `agentPreset.list` owns the configurable preset roster. A selector combines the relevant catalog with the Session's `modelSelection` or `agentPreset` projection only when both inputs are ready. During refresh it may retain the last complete catalog; before the first complete pair it reports loading instead of rendering a guessed name or availability verdict.

View file

@ -108,11 +108,11 @@ Projection 的三种交付状态含义不同:
| Follow opening baseline | 对当前 Host composition 完整 | 精确 opening cursor | Capability 不存在 |
| Projection frame | 单个完整 key | Frame 携带的 event sequence | 不适用 |
Client 为每个 key 保存带 sequence number 的一行。更新的 hint、baseline 或 frame 会替换 row;相同或更旧的输入被忽略。因此 reconnect 可以替换 event window,而不会回退已经在更晚 sequence 接受的 projection frame。
Client 为每个 key 保存带来源与 sequence number 的一行。List hint 只能填充或推进暂定 row。完整 opening baseline 即使面对声称更高 sequence 的 cache hint,也会替换或清除暂定 row,同时保留晚于 opening cut 的权威 frame。Frame 继续使用 higher-sequence-wins,并会把相同 sequence 的 hint 提升为权威状态。Replacement control baseline 会先丢弃超出其 durable cut 的 row,再安装完整值。
List view 与已打开 Session 读取同一个 per-Session store。Hints 可以在 follow 完成前填充 title、preset 和其他 list presentation;opening baseline 随后收敛这份状态,而不会建立第二套 summary-only authority。
每个 Session 的 Client projection store 按一条 higher-sequence-wins 规则接收 list hints、follow baseline 和后续 whole-value frame。它从不折叠 Session event。Baseline 或 frame 可以推进 hinted value,较旧切面不能覆盖较新的 row。
每个 Session 的 Client projection store 从不折叠 Session event;它只按上述来源感知规则协调成品 hint、完整 baseline 与 whole-value frame。
不由单个 Session 派生的数据不进入 projection。`llm.models` 拥有当前 Host generation 的 model catalog,`agentPreset.list` 拥有可配置 preset roster。Selector 只在相应 catalog 与 Session 的 `modelSelection` 或 `agentPreset` projection 均就绪后组合两者。刷新时可以保留上一份完整 catalog;第一次获得完整输入前显示 loading,而不是展示猜测的名称或可用性结论。

View file

@ -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: 8543498400d5f8f2e0230db5898296f9942c5640
config-catalog.zh.md: a162650a53a7e42f61a695120673d804b3737f68
config-catalog.md: 9ac708ebf60e12c9c061a0d809ca5ac7eebfd8a4
config-catalog.zh.md: f32e55abc884ee8b76ea4fdf47bd7601649d9af5

View file

@ -1839,7 +1839,7 @@ export interface Config {
}
```
Source: [`packages/session/session-projection-cache/src/index.ts:46`](../packages/session/session-projection-cache/src/index.ts)
Source: [`packages/session/session-projection-cache/src/index.ts:47`](../packages/session/session-projection-cache/src/index.ts)
<a id="deepseek-aidsh-session-query-sqlite"></a>

View file

@ -1841,7 +1841,7 @@ export interface Config {
}
```
来源:[`packages/session/session-projection-cache/src/index.ts:46`](../packages/session/session-projection-cache/src/index.ts)
来源:[`packages/session/session-projection-cache/src/index.ts:47`](../packages/session/session-projection-cache/src/index.ts)
<a id="deepseek-aidsh-session-query-sqlite"></a>

View file

@ -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: 2e64dca6b22b39771f6ab5ccc594e894a9b3785f
session-projection.zh.md: 356668e24e12e95b2b8abb92c4878a15f9ecec07
session-projection.md: 289e69e4f03020beed835ff6c7012a4b6934dfe3
session-projection.zh.md: 14b0acd8a7ee3ef33e85ec122fb29625ba352399

View file

@ -118,12 +118,11 @@ 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, {@link coldSnapshot}) supersede these
* values whenever a session is actually opened.
* rows (version-matching keys only), each cut carried with its watermark so
* a client value store can prewarm tentative rows. The caller's header keeps
* unrelated lifecycles out, but a row may lag the log or overreach a
* crash-repaired truncation; the exact history or {@link coldSnapshot}
* baseline replaces or clears hints whenever a session is opened.
* @param meta - the listed session's header (identity witness; no log read).
* @param keys - optional projection keys required by the caller's audience.
* @returns the cut (`asOfSeq` = lowest served-row watermark), or

View file

@ -118,12 +118,11 @@ 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, {@link coldSnapshot}) supersede these
* values whenever a session is actually opened.
* rows (version-matching keys only), each cut carried with its watermark so
* a client value store can prewarm tentative rows. The caller's header keeps
* unrelated lifecycles out, but a row may lag the log or overreach a
* crash-repaired truncation; the exact history or {@link coldSnapshot}
* baseline replaces or clears hints whenever a session is opened.
* @param meta - the listed session's header (identity witness; no log read).
* @param keys - optional projection keys required by the caller's audience.
* @returns the cut (`asOfSeq` = lowest served-row watermark), or

View file

@ -491,18 +491,16 @@ export class SessionManager {
session.handleBlank(s.blank)
session.handleRunning(s.running)
}
// Seed each row's projection baseline into the per-session value
// store (cold titles surface without opening the session). Per-key
// apply, not seed(): the list block is a partial baseline — the
// cold cache serves only version-matching keys — so an absent key
// must not clear; higher-seq-wins still keeps a stale list block
// from overwriting a newer push frame or tail baseline.
// Prewarm each row's projection hints (cold titles surface without
// opening the session). The list block is partial, so an absent key
// must not clear; hints never replace an authoritative frame or
// successful opening baseline, even if the cache claims a higher cut.
for (const s of result.value.items) {
const block = s.projections
if (block === undefined) continue
const store = this.projectionStore(s.sessionId)
const values = block.values as Record<string, unknown>
for (const key of Object.keys(values)) store.apply(key, values[key], block.asOfSeq)
for (const key of Object.keys(values)) store.prewarm(key, values[key], block.asOfSeq)
}
} else {
this.listState = 'error'
@ -725,7 +723,7 @@ export class SessionManager {
if (projections !== undefined) {
const store = this.projectionStore(summary.sessionId)
for (const [key, value] of Object.entries(projections.values)) {
store.apply(key, value, projections.asOfSeq)
store.prewarm(key, value, projections.asOfSeq)
}
}
if (summary.origin === 'subagent' && summary.parentSessionId !== undefined) {

View file

@ -2,11 +2,13 @@
* Generic per-session projection value store (push model; see the
* session-projection subsystem page, docs/subsystems/session-projection.md):
* the host is the only computation site; the client holds finished
* whole values per key — `key → { value, seq }` — seeded by a follow opening
* baseline and updated by Session Controller `projection` frames,
* under the single rule **higher seq wins**. No client-side domain folding
* exists: a domain ships projection support with zero client code. Per-key
* bare observable faces feed `useProjection` (ui-renderer binds them).
* whole values per key — `key → { value, seq, provenance }`. Session-list and
* session-added blocks are tentative prewarm hints; a successful follow
* opening installs the complete authoritative baseline, and Session Controller
* `projection` frames advance authoritative rows by sequence. No client-side
* domain folding exists: a domain ships projection support with zero client
* code. Per-key bare observable faces feed `useProjection` (ui-renderer binds
* them).
*/
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
@ -51,10 +53,11 @@ export interface ProjectionsBaseline {
values: Readonly<Record<string, unknown>>
}
/** One key's row: the latest finished value and the seq it is consistent with. */
/** One key's row: the latest finished value, its cut, and its trust level. */
interface Row {
value: unknown
seq: number
provenance: 'prewarm' | 'authoritative'
}
/** Per-key notification channel: the bare face plus its batching notifier. */
@ -64,14 +67,15 @@ interface Channel {
}
/**
* One session's projection values. Framework semantics, uniform across every
* key: a baseline seeds rows at its cut, a push frame updates one row, and in
* both paths a lower-or-equal seq loses — a replayed frame cannot regress a
* value, a stale baseline cannot overwrite a newer frame. A key the store has
* never seen reads `undefined` (capability absent). Faces are identity-stable
* per key (create-on-demand, cached) so the React side binds each exactly
* once; the store-level channel (`subscribeAny`) serves coarse consumers (the
* manager's list projection reads the `title` key).
* One session's projection values. A list hint can fill or advance only a
* tentative row. A complete baseline replaces or clears every tentative row,
* regardless of its claimed sequence, while preserving authoritative frames
* newer than the baseline cut. Frames use higher-sequence-wins after promoting
* an equal-sequence hint to authoritative state. A key the store has never seen
* reads `undefined` (capability absent). Faces are identity-stable per key
* (create-on-demand, cached) so the React side binds each exactly once; the
* store-level channel (`subscribeAny`) serves coarse consumers (the manager's
* list projection reads the `title` key).
*/
export class ProjectionValueStore {
private readonly rows = new Map<string, Row>()
@ -124,6 +128,22 @@ export class ProjectionValueStore {
return this.anyNotifier.subscribe(listener)
}
/**
* Prewarm one tentative value from a partial Session list or session-added
* block. Hints compete only with other hints; once an authoritative value is
* known, no later list refresh may replace it.
* @param key - projection key.
* @param value - whole cached value.
* @param seq - the cache row's claimed watermark.
*/
prewarm(key: string, value: unknown, seq: number): void {
const row = this.rows.get(key)
if (row?.provenance === 'authoritative') return
if (row !== undefined && seq <= row.seq) return
this.rows.set(key, { value, seq, provenance: 'prewarm' })
this.changed(key)
}
/**
* Apply one finished value from the Session control stream.
* @param key - projection key.
@ -132,27 +152,31 @@ export class ProjectionValueStore {
*/
apply(key: string, value: unknown, seq: number): void {
const row = this.rows.get(key)
if (row !== undefined && seq <= row.seq) return // higher seq wins; replays and stale frames drop
this.rows.set(key, { value, seq })
if (row !== undefined && (seq < row.seq || (seq === row.seq && row.provenance === 'authoritative'))) return
this.rows.set(key, { value, seq, provenance: 'authoritative' })
this.changed(key)
}
/**
* Seed from a history tail page's projections block: every carried key
* lands under the same seq rule as frames; a key the block omits is
* capability-absent as of the cut — its row clears unless a newer frame
* already superseded the cut (a stale baseline can neither overwrite nor
* clear newer values).
* Seed from a complete history or control projections block. The baseline
* replaces every tentative hint, including one whose cache watermark is
* higher, and clears omitted hints. Only an authoritative frame newer than
* the cut survives.
* @param baseline - the response's projections block.
*/
seed(baseline: ProjectionsBaseline): void {
// Erased walk: the framework crosses the open key space; per-key typing
// is re-established at the consumer (useProjection's map lookup).
const values = baseline.values as Record<string, unknown>
for (const key of Object.keys(values)) this.apply(key, values[key], baseline.asOfSeq)
for (const key of Object.keys(values)) {
const row = this.rows.get(key)
if (row?.provenance === 'authoritative' && row.seq > baseline.asOfSeq) continue
this.rows.set(key, { value: values[key], seq: baseline.asOfSeq, provenance: 'authoritative' })
this.changed(key)
}
for (const [key, row] of this.rows) {
if (Object.hasOwn(values, key)) continue
if (row.seq > baseline.asOfSeq) continue
if (row.provenance === 'authoritative' && row.seq > baseline.asOfSeq) continue
this.rows.delete(key)
this.changed(key)
}

View file

@ -107,9 +107,10 @@ export class Session implements SessionFace {
/**
* Per-session projection value store (push model; see the session-projection
* subsystem page, docs/subsystems/session-projection.md): finished whole
* values computed on the Host, seeded by the tail page's
* projections block and updated by Session Controller control frames under the
* one higher-seq-wins rule. Keys are read via `projections.faceOf(key)`
* values computed on the Host. Partial list blocks prewarm tentative rows;
* the tail page installs the complete authoritative baseline, and Session
* Controller frames advance authoritative rows by sequence. Keys are read
* via `projections.faceOf(key)`
* (the useProjection resolution face); the conversation snapshot never
* carries projection values, and no client-side domain folding exists.
* Manager-owned when constructed through SessionManager (frames route and
@ -574,7 +575,7 @@ export class Session implements SessionFace {
}
}
/** Replace the complete contiguous window and apply page-owned projection metadata. */
/** Replace the complete contiguous window and install its authoritative projection baseline. */
private installWindow(entries: readonly SessionEventLikeEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void {
this.baseSeq = entries[0]?.event.seq ?? 0
this.hasMore = hasMore

View file

@ -151,25 +151,30 @@ describe('list lifecycle', () => {
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined()
})
it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => {
it('prewarms cold titles from list and session-added hints without replacing authoritative values', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api, fakeRemote(api))
// A push frame landed before the list (S2's title is newer than the block's cut).
// A push frame landed before the list. Even a later cache watermark stays
// tentative and cannot replace this authoritative value.
manager.handleControlFrame({
type: 'projection', sessionId: S2, key: 'title', value: 'Pushed', seq: 9,
})
api.onList = () => Promise.resolve(ok({
items: [
{ ...summary(S1), projections: { asOfSeq: 4, values: { title: 'Cold cached' } } },
{ ...summary(S2, { updatedAt: 200 }), projections: { asOfSeq: 5, values: { title: 'List stale' } } },
{ ...summary(S2, { updatedAt: 200 }), projections: { asOfSeq: 12, values: { title: 'List stale' } } },
] as never[],
}))
await manager.refreshList()
const items = manager.getListSnapshot().items
// Cold row: title surfaces straight from the list block — no open, no history.
expect(items.find(item => item.sessionId === S1)?.title).toBe('Cold cached')
// The stale list block (seq 5) cannot overwrite the newer push frame (seq 9).
expect(items.find(item => item.sessionId === S2)?.title).toBe('Pushed')
manager.handleSessionAdded({
...summary(S2, { updatedAt: 300 }),
projections: { asOfSeq: 15, values: { title: 'Added stale' } },
})
expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)?.title).toBe('Pushed')
})
it('drops a projection row beyond the subscription baseline before accepting its durable replay', async () => {

View file

@ -1,18 +1,17 @@
/**
* Projection value store (push model; session-projection subsystem page:
* docs/subsystems/session-projection.md): the single
* higher-seq-wins rule on both paths (a stale baseline cannot overwrite a
* newer push frame; a replayed frame cannot regress), capability absence as
* undefined, generation truncation, and the Session/manager wiring (tail-page
* seeding, control-stream projection routing pre- and post-instantiation, the
* list rows' title projection).
* docs/subsystems/session-projection.md): tentative list prewarm versus
* authoritative baselines and frames, capability absence as undefined,
* generation truncation, and the Session/manager wiring (tail-page seeding,
* control-stream projection routing pre- and post-instantiation, the list
* rows' title projection).
*/
import { describe, expect, it } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import { ProjectionValueStore } from '../src/client/sessions/projection-store.ts'
import { Session } from '../src/client/sessions/session.ts'
import { SessionManager } from '../src/client/sessions/manager.ts'
import { FakeApiClient, err, fakeRemote, ok } from './fake-api.client.ts'
import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
import { entries, plainTurn } from './event-script.client.ts'
// Test-domain keys merged into the projection map (the Service Definition package's
@ -42,18 +41,31 @@ describe('Session projection value semantics', () => {
expect(store.get('test/marks')).toEqual({ marks: ['a', 'b'] })
})
it('a stale baseline can neither overwrite nor clear a newer frame; a fresh one reseeds and clears', () => {
it('prewarms only tentative rows and promotes an equal-seq authoritative frame', () => {
const store = new ProjectionValueStore()
store.apply('test/marks', { marks: ['frame-20'] }, 20)
// Stale cut: carried key loses to the newer frame; omitted key survives.
store.prewarm('test/marks', { marks: ['hint-5'] }, 5)
store.prewarm('test/marks', { marks: ['stale-hint'] }, 3)
expect(store.get('test/marks')).toEqual({ marks: ['hint-5'] })
store.prewarm('test/marks', { marks: ['hint-9'] }, 9)
store.apply('test/marks', { marks: ['frame-9'] }, 9)
store.prewarm('test/marks', { marks: ['later-hint'] }, 20)
expect(store.get('test/marks')).toEqual({ marks: ['frame-9'] })
})
it('a complete baseline replaces hints but preserves newer authoritative frames', () => {
const store = new ProjectionValueStore()
store.prewarm('test/marks', { marks: ['hint-20'] }, 20)
store.prewarm('hint-only', 'stale', 20)
store.apply('frame-only', 'frame-20', 20)
store.seed({ asOfSeq: 10, values: { 'test/marks': { marks: ['baseline-10'] } } })
expect(store.get('test/marks')).toEqual({ marks: ['baseline-10'] })
expect(store.get('hint-only')).toBeUndefined()
expect(store.get('frame-only')).toBe('frame-20')
store.apply('test/marks', { marks: ['frame-20'] }, 20)
store.seed({ asOfSeq: 15, values: { 'test/marks': { marks: ['baseline-15'] } } })
expect(store.get('test/marks')).toEqual({ marks: ['frame-20'] })
store.seed({ asOfSeq: 15, values: {} })
expect(store.get('test/marks')).toEqual({ marks: ['frame-20'] })
// Fresh cut: carried key reseeds…
store.seed({ asOfSeq: 30, values: { 'test/marks': { marks: ['baseline-30'] } } })
expect(store.get('test/marks')).toEqual({ marks: ['baseline-30'] })
// …and an omitting fresh cut clears (capability absent as of the cut).
store.seed({ asOfSeq: 40, values: {} })
expect(store.get('test/marks')).toBeUndefined()
})
@ -104,7 +116,7 @@ describe('Session tail-page seeding', () => {
it('retains a prewarmed projection when opening the Session fails', async () => {
const api = new FakeApiClient()
const projections = new ProjectionValueStore()
projections.apply('test/marks', { marks: ['cached'] }, 5)
projections.prewarm('test/marks', { marks: ['cached'] }, 5)
const session = new Session(SID, api, fakeRemote(api), { projections })
api.onHistory = () => Promise.resolve(err({
code: 'session-not-found',
@ -129,6 +141,39 @@ describe('Session tail-page seeding', () => {
expect(session.projections.get('test/marks')).toEqual({ marks: ['from-baseline'] })
})
it('replaces a higher-seq prewarm hint after a successful opening', async () => {
const api = new FakeApiClient()
const projections = new ProjectionValueStore()
projections.prewarm('test/marks', { marks: ['stale-list'] }, 9)
const session = new Session(SID, api, fakeRemote(api), { projections })
api.onHistory = () => Promise.resolve(ok({
records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false,
projections: { asOfSeq: 2, values: { 'test/marks': { marks: ['authoritative'] } } },
} as never))
await session.open()
expect(session.getSnapshot().openState).toBe('open')
expect(session.projections.get('test/marks')).toEqual({ marks: ['authoritative'] })
})
it('preserves an authoritative frame that lands while opening waits for its older baseline', async () => {
const api = new FakeApiClient()
const history = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => history.promise
const session = new Session(SID, api, fakeRemote(api))
const opening = session.open()
session.projections.apply('test/marks', { marks: ['live-3'] }, 3)
history.resolve(ok({
records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false,
projections: { asOfSeq: 2, values: { 'test/marks': { marks: ['baseline-2'] } } },
} as never))
await opening
expect(session.projections.get('test/marks')).toEqual({ marks: ['live-3'] })
})
it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api, fakeRemote(api))

View file

@ -138,54 +138,59 @@ export function ScheduleCatalogAction({ useSession, useProjection, t }: Schedule
setOpen(false)
triggerRef.current?.focus()
}
const toggleCatalog = (): void => {
setNow(Date.now())
setOpen(current => !current)
}
const trigger = (
<button
ref={triggerRef}
type="button"
className={css.trigger}
aria-expanded={open}
aria-label={countLabel}
onClick={toggleCatalog}
>
<ScheduleClockIcon />
<span className={css.count}>{countLabel}</span>
<IconChevronDownOutline14 className={open ? css.triggerOpen : undefined} />
</button>
)
const catalog = open
? (
<ul className={css.menu} aria-label={t('list.aria')}>
{rows.map((record) => {
const overdue = Date.parse(record.scheduledAt) <= now
return (
<li
key={record.id}
className={overdue ? `${css.row} ${css.rowOverdue}` : css.row}
>
<span className={css.status}>
<span className={css.statusDot} aria-hidden="true" />
<span>{t(overdue ? 'status.overdue' : 'status.scheduled')}</span>
</span>
<span className={css.prompt}>{record.prompt}</span>
<span className={css.metadata}>
<span>{formatScheduleFrequency(record, t)}</span>
<span aria-hidden="true">·</span>
<span>{formatScheduleLocalTime(record.scheduledAt)}</span>
<span aria-hidden="true">·</span>
<span className={overdue ? css.relativeOverdue : css.relative}>
{formatScheduleRelative(record.scheduledAt, now, t)}
</span>
</span>
</li>
)
})}
</ul>
)
: null
return (
<div ref={rootRef} className={css.root} onKeyDown={onKeyDown}>
<button
ref={triggerRef}
type="button"
className={css.trigger}
aria-expanded={open}
aria-label={countLabel}
onClick={() => {
setNow(Date.now())
setOpen(current => !current)
}}
>
<ScheduleClockIcon />
<span className={css.count}>{countLabel}</span>
<IconChevronDownOutline14 className={open ? css.triggerOpen : undefined} />
</button>
{open
? (
<ul className={css.menu} aria-label={t('list.aria')}>
{rows.map((record) => {
const overdue = Date.parse(record.scheduledAt) <= now
return (
<li
key={record.id}
className={overdue ? `${css.row} ${css.rowOverdue}` : css.row}
>
<span className={css.status}>
<span className={css.statusDot} aria-hidden="true" />
<span>{t(overdue ? 'status.overdue' : 'status.scheduled')}</span>
</span>
<span className={css.prompt}>{record.prompt}</span>
<span className={css.metadata}>
<span>{formatScheduleFrequency(record, t)}</span>
<span aria-hidden="true">·</span>
<span>{formatScheduleLocalTime(record.scheduledAt)}</span>
<span aria-hidden="true">·</span>
<span className={overdue ? css.relativeOverdue : css.relative}>
{formatScheduleRelative(record.scheduledAt, now, t)}
</span>
</span>
</li>
)
})}
</ul>
)
: null}
{trigger}
{catalog}
</div>
)
}

View file

@ -1400,7 +1400,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
methods: [
{
signature: 'cachedSnapshot( meta: SessionHeader, keys?: readonly Extract<keyof SessionProjectionMap, string>[], ): 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, coldSnapshot) supersede these values whenever a session is actually opened.',
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 prewarm tentative rows. The caller\'s header keeps unrelated lifecycles out, but a row may lag the log or overreach a crash-repaired truncation; the exact history or coldSnapshot baseline replaces or clears hints whenever a 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 cut (`asOfSeq` = lowest served-row watermark), or `undefined` when no usable row exists for this lifecycle.',
},

View file

@ -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: 33908578a5127f2b6bb78ed7467833aaaa2cf085
README.zh.md: 0ca410f91562360d85faadf4cf64cb61ac467482
README.md: bddff27bf89c31049c72ed2e8027f452cd6fbae2
README.zh.md: 3695fff30cb3c7741f448f0bb46660fd6a73a331

View file

@ -28,7 +28,7 @@ Both `Config` fields are required (no defaults): flush cadence is a deployment c
## Listing read (`cachedSnapshot(meta)`)
The zero-I/O rung: 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, or no usable rows); the api-proxy list carrier turns that into an absent column.
The zero-I/O rung: 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, and the list carrier uses the block only to prewarm tentative rows. Newer hints may replace older hints, but no hint replaces an authoritative opening baseline or control frame; a successful exact opening replaces or clears hints regardless of their claimed sequence. The record may lag the log or overreach a crash-repaired truncation, which the exact cold/open path validates and refolds. Host-only rows are never returned. `undefined` means no usable client row exists (unknown id, unrelated lifecycle, or no usable rows); the api-proxy list carrier turns that into an absent column.
## Cold read (`coldSnapshot(id, signal?)`)

View file

@ -28,7 +28,7 @@
## 列表读(`cachedSnapshot(meta)`)
零 I/O 一档:从身份匹配的存储记录直接 view 客户端值(仅版本与 state schema 均匹配的 key),以 `{asOfSeq, values}` 切面返回——`asOfSeq` 取所服务行的最低水位,客户端在 higher-seq-wins 规则下播种值存储时,陈旧列表块永远压不过更新的推送帧。host-only 行永不返回。无可用客户端行(未知 id、无关生命周期、无可用行)时返回 `undefined`;api-proxy 列表载体将其转为列缺席。
零 I/O 一档:从身份匹配的存储记录直接 view 客户端值(仅版本与 state schema 均匹配的 key),以 `{asOfSeq, values}` 切面返回。`asOfSeq` 取所服务行的最低水位,列表载体只用该块预热暂定 row。较新的 hint 可以替换较旧的 hint,但任何 hint 都不能替换权威 opening baseline 或 control frame;成功的精确打开会忽略 hint 声称的 sequence,直接替换或清除它。存储记录可能落后于日志,也可能越过崩溃修复后的截断点;精确 cold/open 路径会校验并重新折叠。host-only 行永不返回。`undefined` 表示无可用客户端行(未知 id、无关生命周期或无可用行);api-proxy 列表载体将其转为列缺席。
## 冷读(`coldSnapshot(id, signal?)`)

View file

@ -3,10 +3,11 @@
* checkpoints of every client-visible or explicitly persisted projection unit's state, one record per
* session on the domain data form (`session_projcache` domain — the shipped
* json backend lands it beside `workspace.json`). The cache is a fold
* shortcut, never an authority: a row is possibly stale (its `seq`
* says how stale) but never wrong, so every write path is fail-soft (a lost
* write costs a longer tail replay on the next cold read) and a
* `ver` mismatch discards the row instead of migrating it. Design
* shortcut, never an authority: an identity-matching row may lag the log or
* overreach a crash-repaired truncation, so exact reads validate and refold it.
* Every write path is fail-soft (a lost write costs a longer tail replay on
* the next cold read), and a `ver` mismatch discards the row instead of
* migrating it. Design
* authority: the session-projection RFC
* (.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md).
* @module @deepseek-ai/dsh-session-projection-cache
@ -110,12 +111,11 @@ 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, {@link coldSnapshot}) supersede these
* values whenever a session is actually opened.
* rows (version-matching keys only), each cut carried with its watermark so
* a client value store can prewarm tentative rows. The caller's header keeps
* unrelated lifecycles out, but a row may lag the log or overreach a
* crash-repaired truncation; the exact history or {@link coldSnapshot}
* baseline replaces or clears hints whenever a session is opened.
* @param meta - the listed session's header (identity witness; no log read).
* @param keys - optional projection keys required by the caller's audience.
* @returns the cut (`asOfSeq` = lowest served-row watermark), or
@ -131,8 +131,8 @@ export class SessionProjectionCache extends Service {
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).
// value is at least current as of. Under-claiming is safe; over-claiming
// would misorder this hint against other tentative observations.
const asOfSeq = Math.min(...servedKeys.map(key => (record.rows[key] as { seq: number }).seq))
return { asOfSeq, values }
}