mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-25 08:34:39 +00:00
fix: serve the canonical title state from the session index and version the read-model cache
readSummary now derives the title state with the same priority chain as the metadata document's canonical normalization (explicit custom marker, valid titleKind, legacy false marker, customTitle, plain title), so list and resume agree on legacy documents too. Read-model cache entries carry a summary version stamp and older-stamped entries are treated as cold misses, so an upgraded reader never serves a stale-shaped summary.
This commit is contained in:
parent
f4445aaf9e
commit
0e89f70bf6
5 changed files with 69 additions and 16 deletions
|
|
@ -740,7 +740,7 @@ export interface AgentStateSnapshot {
|
|||
'llmRequester.lastConfigLogSignature': string | undefined;
|
||||
'llmRequester.mediaDegradedTurns': Set<number>;
|
||||
'llmRequester.mediaStrippedTurns': Map<number, /* MediaStripSnapshot — packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts */ {
|
||||
readonly "__@mediaStripSnapshotBrand@2244": undefined;
|
||||
readonly "__@mediaStripSnapshotBrand@2247": undefined;
|
||||
}>;
|
||||
'llmRequester.turnConfigs': Map<number, /* TurnRequestConfig — packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts */ {
|
||||
readonly resolved: /* ProfileModelContext — packages/agent-core-v2/src/agent/profile/profile.ts */ {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@
|
|||
* data (`SessionSummary`) or counts — never filesystem paths or live handles.
|
||||
* The summary mirrors `sessionMetadata`'s title state (`title` / `titleKind`);
|
||||
* the kind union is re-declared inline because this L2 contract cannot
|
||||
* import the L6 metadata domain that canonically owns it.
|
||||
* import the L6 metadata domain that canonically owns it. Entries cached in
|
||||
* the derived read model carry `READ_MODEL_SUMMARY_VERSION`; older-stamped
|
||||
* (or un-stamped) entries are treated as cold misses and backfilled from
|
||||
* disk, so upgraded readers never serve a stale-shape summary.
|
||||
* Writes (create / archive) live in `workspaceHandler` / `session`; the index
|
||||
* is a read model. Backends are deployment-specific (local filesystem today;
|
||||
* database / query store on a server).
|
||||
|
|
@ -16,6 +19,8 @@
|
|||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
import type { Page } from '#/persistence/interface/queryStore';
|
||||
|
||||
export const READ_MODEL_SUMMARY_VERSION = 2;
|
||||
|
||||
export const PARENT_SESSION_ID_KEY = 'parent_session_id';
|
||||
|
||||
export const CHILD_SESSION_KIND_KEY = 'child_session_kind';
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ import {
|
|||
CHILD_SESSION_KIND_KEY,
|
||||
ISessionIndex,
|
||||
PARENT_SESSION_ID_KEY,
|
||||
READ_MODEL_SUMMARY_VERSION,
|
||||
type SessionListQuery,
|
||||
type SessionSummary,
|
||||
} from './sessionIndex';
|
||||
|
|
@ -62,6 +63,8 @@ const META_KEY = 'state.json';
|
|||
const SESSION_COLLECTION = 'session';
|
||||
const READ_MODEL_FLAG = 'persistence_minidb_readmodel';
|
||||
|
||||
type CachedSessionSummary = SessionSummary & { readonly v: number };
|
||||
|
||||
function parseTime(value: unknown): number {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string') {
|
||||
|
|
@ -71,13 +74,19 @@ function parseTime(value: unknown): number {
|
|||
return 0;
|
||||
}
|
||||
|
||||
function toTitleKind(titleKind: unknown, isCustomTitle: unknown): SessionSummary['titleKind'] {
|
||||
// Same priority as the metadata document's canonical normalization: a
|
||||
// legacy writer's explicit custom marker outranks a stale titleKind.
|
||||
if (isCustomTitle === true) return 'custom';
|
||||
return titleKind === 'replaceable' || titleKind === 'generated' || titleKind === 'custom'
|
||||
? titleKind
|
||||
: undefined;
|
||||
function toTitleKind(meta: Record<string, unknown>): SessionSummary['titleKind'] {
|
||||
const title = typeof meta['title'] === 'string' ? meta['title'] : undefined;
|
||||
const titleKind = meta['titleKind'];
|
||||
if (title !== undefined && meta['isCustomTitle'] === true) return 'custom';
|
||||
if (
|
||||
title !== undefined &&
|
||||
(titleKind === 'replaceable' || titleKind === 'generated' || titleKind === 'custom')
|
||||
) {
|
||||
return titleKind;
|
||||
}
|
||||
if (title !== undefined && meta['isCustomTitle'] === false) return 'replaceable';
|
||||
if (typeof meta['customTitle'] === 'string') return 'custom';
|
||||
return title === undefined ? undefined : 'replaceable';
|
||||
}
|
||||
|
||||
function recoverCwd(meta: Record<string, unknown>): string | undefined {
|
||||
|
|
@ -110,10 +119,11 @@ function matchesChildOf(summary: SessionSummary, parentId: string | undefined):
|
|||
* fields the session-summary contract requires; anything else is treated as a
|
||||
* cold miss and rebuilt from disk.
|
||||
*/
|
||||
function isSessionSummaryShape(value: unknown): value is SessionSummary {
|
||||
function isSessionSummaryShape(value: unknown): value is CachedSessionSummary {
|
||||
if (value === null || typeof value !== 'object') return false;
|
||||
const summary = value as Record<string, unknown>;
|
||||
return (
|
||||
summary['v'] === READ_MODEL_SUMMARY_VERSION &&
|
||||
typeof summary['id'] === 'string' &&
|
||||
typeof summary['workspaceId'] === 'string' &&
|
||||
typeof summary['createdAt'] === 'number' &&
|
||||
|
|
@ -247,11 +257,17 @@ export class FileSessionIndex implements ISessionIndex {
|
|||
sessionId: string,
|
||||
): Promise<SessionSummary | undefined> {
|
||||
const cached: unknown = await this.queryStore.get(SESSION_COLLECTION, sessionId);
|
||||
if (isSessionSummaryShape(cached)) return cached;
|
||||
if (isSessionSummaryShape(cached)) {
|
||||
const { v: _version, ...summary } = cached;
|
||||
return summary;
|
||||
}
|
||||
const summary = await this.readSummary(workspaceId, sessionId);
|
||||
if (summary !== undefined) {
|
||||
// Also overwrites a cache entry that failed the shape check above.
|
||||
await this.queryStore.put(SESSION_COLLECTION, sessionId, summary);
|
||||
await this.queryStore.put(SESSION_COLLECTION, sessionId, {
|
||||
...summary,
|
||||
v: READ_MODEL_SUMMARY_VERSION,
|
||||
});
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
|
@ -343,7 +359,7 @@ export class FileSessionIndex implements ISessionIndex {
|
|||
workspaceId,
|
||||
cwd: recoverCwd(meta),
|
||||
title: typeof meta['title'] === 'string' ? meta['title'] : undefined,
|
||||
titleKind: toTitleKind(meta['titleKind'], meta['isCustomTitle']),
|
||||
titleKind: toTitleKind(meta),
|
||||
lastPrompt: typeof meta['lastPrompt'] === 'string' ? meta['lastPrompt'] : undefined,
|
||||
createdAt: parseTime(meta['createdAt']),
|
||||
updatedAt: parseTime(meta['updatedAt']),
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import { Emitter, type Event } from '#/_base/event';
|
|||
import { ILogService } from '#/_base/log/log';
|
||||
import { defineState } from '#/_base/state/stateRegistry';
|
||||
import { IFlagService } from '#/app/flag/flag';
|
||||
import { READ_MODEL_SUMMARY_VERSION } from '#/app/sessionIndex/sessionIndex';
|
||||
import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore';
|
||||
import { IQueryStore } from '#/persistence/interface/queryStore';
|
||||
import { ISessionContext } from '#/session/sessionContext/sessionContext';
|
||||
|
|
@ -174,6 +175,7 @@ export class SessionMetadata extends Disposable implements ISessionMetadata {
|
|||
// poison the cache entry and fail contract validation on reads.
|
||||
archived: this.data.archived === true,
|
||||
custom: this.data.custom,
|
||||
v: READ_MODEL_SUMMARY_VERSION,
|
||||
});
|
||||
} catch (error) {
|
||||
this.log.warn('failed to mirror session metadata to read model', {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,11 @@ import { ILogService } from '#/_base/log/log';
|
|||
import { encodeWorkDirKey } from '#/_base/utils/workdir-slug';
|
||||
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
|
||||
import { IFlagService } from '#/app/flag/flag';
|
||||
import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex';
|
||||
import {
|
||||
ISessionIndex,
|
||||
READ_MODEL_SUMMARY_VERSION,
|
||||
type SessionSummary,
|
||||
} from '#/app/sessionIndex/sessionIndex';
|
||||
import { FileSessionIndex } from '#/app/sessionIndex/sessionIndexService';
|
||||
import { MiniDbQueryStore } from '#/persistence/backends/minidb/miniDbQueryStore';
|
||||
import { JsonAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore';
|
||||
|
|
@ -144,11 +148,13 @@ describe('FileSessionIndex (legacy)', () => {
|
|||
isCustomTitle: true,
|
||||
});
|
||||
await seedSession('plain', { title: 'plain' });
|
||||
await seedSession('legacy-custom', { customTitle: 'legacy title' });
|
||||
|
||||
const store = build();
|
||||
expect((await store.get('generated'))?.titleKind).toBe('generated');
|
||||
expect((await store.get('stale-mixed'))?.titleKind).toBe('custom');
|
||||
expect((await store.get('plain'))?.titleKind).toBeUndefined();
|
||||
expect((await store.get('plain'))?.titleKind).toBe('replaceable');
|
||||
expect((await store.get('legacy-custom'))?.titleKind).toBe('custom');
|
||||
});
|
||||
|
||||
it('list filters by sessionId without enumerating all sessions', async () => {
|
||||
|
|
@ -314,13 +320,14 @@ describe('FileSessionIndex (read model)', () => {
|
|||
await fsp.writeFile(join(dir, 'state.json'), JSON.stringify(meta));
|
||||
}
|
||||
|
||||
function summary(id: string, overrides: Partial<SessionSummary> = {}): SessionSummary {
|
||||
function summary(id: string, overrides: Partial<SessionSummary> = {}) {
|
||||
return {
|
||||
id,
|
||||
workspaceId,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
archived: false,
|
||||
v: READ_MODEL_SUMMARY_VERSION,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
@ -373,6 +380,29 @@ describe('FileSessionIndex (read model)', () => {
|
|||
expect(cached?.archived).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a cache entry stamped with an older summary version as a cold miss', async () => {
|
||||
await seedSession('s1', {
|
||||
title: '用户标题',
|
||||
titleKind: 'replaceable',
|
||||
isCustomTitle: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
});
|
||||
const store = build();
|
||||
await queryStore.put(SESSION_COLLECTION, 's1', {
|
||||
...summary('s1', { title: '用户标题', titleKind: 'replaceable' }),
|
||||
v: 1,
|
||||
});
|
||||
|
||||
const got = await store.get('s1');
|
||||
// The stale-stamped entry is ignored and backfilled from disk, which
|
||||
// honors the legacy custom marker over the stale titleKind.
|
||||
expect(got?.titleKind).toBe('custom');
|
||||
const cached = await queryStore.get<Record<string, unknown>>(SESSION_COLLECTION, 's1');
|
||||
expect(cached?.['v']).toBe(READ_MODEL_SUMMARY_VERSION);
|
||||
expect(cached?.['titleKind']).toBe('custom');
|
||||
});
|
||||
|
||||
it('get falls back to disk when the cached entry fails the shape check', async () => {
|
||||
await seedSession('s1', { title: 'on-disk', createdAt: 1, updatedAt: 2 });
|
||||
const store = build();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue