Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/search-deleted-sessions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Stop returning deleted sessions from global search before the search index catches up.
6 changes: 6 additions & 0 deletions packages/kap-server/src/search/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export const MAX_DOC_TEXT_CHARS = 20_000;

export interface MessageDoc {
readonly kind: 'message';
readonly sessionIdentity?: string;
readonly sessionId: string;
readonly workspaceId: string;
readonly sessionTitle: string;
Expand All @@ -15,6 +16,7 @@ export interface MessageDoc {

export interface TitleDoc {
readonly kind: 'title';
readonly sessionIdentity?: string;
readonly sessionId: string;
readonly workspaceId: string;
readonly sessionTitle: string;
Expand Down Expand Up @@ -56,10 +58,14 @@ export interface FileMetaDoc {

export interface SessionMetaDoc {
readonly kind: 'sessionMeta';
readonly title?: string;
readonly dir?: string;
readonly identity?: string;
}

export interface StatsDoc {
readonly kind: 'stats';
readonly degraded?: string;
readonly sessions: number;
readonly documents: number;
readonly lastIndexedAt: number;
Expand Down
151 changes: 135 additions & 16 deletions packages/kap-server/src/search/indexCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,60 @@ function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

async function sessionDirectoryIdentity(dir: string): Promise<string | undefined> {
try {
const info = await stat(dir, { bigint: true });
if (!info.isDirectory() || info.ino <= 0n || info.birthtimeNs <= 0n) return undefined;
return `${info.dev}:${info.ino}:${info.birthtimeNs}`;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT' || code === 'ENOTDIR') return undefined;
throw error;
}
}

const querySourceChecks = { running: false, waiters: new Set<() => void>() };

async function queryDirectoryIdentity(dir: string, deadlineAt: number, deadline: Promise<null>): Promise<string | undefined | null> {
while (querySourceChecks.running) {
let wake!: () => void;
const available = new Promise<boolean>((resolve) => { wake = () => { resolve(true); }; });
querySourceChecks.waiters.add(wake);
try {
if (await Promise.race([available, deadline]) === null) return null;
} finally {
querySourceChecks.waiters.delete(wake);
}
}
if (Date.now() >= deadlineAt) return null;
querySourceChecks.running = true;
const identity = sessionDirectoryIdentity(dir);
const release = () => {
querySourceChecks.running = false;
for (const wake of querySourceChecks.waiters) wake();
querySourceChecks.waiters.clear();
};
void identity.then(release, release);
return Promise.race([identity, deadline]);
}

async function sessionDirectoryTitle(dir: string, log: SearchCoreLog): Promise<string> {
for (const scope of ['', 'session-meta']) {
try {
const meta: unknown = JSON.parse(await readFile(join(dir, scope, 'state.json'), 'utf8'));
if (typeof meta === 'object' && meta !== null && 'title' in meta && typeof meta.title === 'string') {
return meta.title;
}
return '';
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
log.warn('search index: cannot read session title', { dir, error: errorMessage(error) });
}
}
}
return '';
}

const INITIAL_TURN_STATE: TurnCounterState = { next: 0, hasTurn: false, openers: [] };

function initialTurnState(): TurnCounterState {
Expand Down Expand Up @@ -459,8 +513,7 @@ export class SearchIndexCore {
for (const summary of sessions) {
if (this.disposed) return { noop: true, sessions: 0, documents: 0 };
try {
await this.syncSession(db, summary);
indexed++;
if (await this.syncSession(db, summary)) indexed++;
} catch (error) {
this.log.warn('global search: failed to index session', {
sessionId: summary.id,
Expand All @@ -473,6 +526,7 @@ export class SearchIndexCore {
const metaCount = db.query({ key: { prefix: '\0meta\\' }, project: [] }).length;
const stats: StatsDoc = {
kind: 'stats',
degraded: indexed < sessions.length ? `Skipped ${sessions.length - indexed} session(s) during indexing` : undefined,
sessions: indexed,
documents: db.size - metaCount,
lastIndexedAt: Date.now(),
Expand Down Expand Up @@ -510,7 +564,23 @@ export class SearchIndexCore {
await db.del(SESSION_META_PREFIX + sessionId);
}

private async syncSession(db: MiniDb<SearchDoc>, summary: SyncSessionInput): Promise<void> {
private async syncSession(db: MiniDb<SearchDoc>, summary: SyncSessionInput): Promise<boolean> {
const identity = await sessionDirectoryIdentity(summary.dir);
if (identity === undefined) {
await this.deleteSessionDocs(db, summary.id);
return false;
}
const title = await sessionDirectoryTitle(summary.dir, this.log);
const metaKey = SESSION_META_PREFIX + summary.id;
const previous = db.get(metaKey);
if (previous?.kind !== 'sessionMeta' || previous.identity !== identity || previous.dir !== summary.dir) {
await this.deleteSessionDocs(db, summary.id);
if (previous !== undefined) this.syncReplaced = true;
}
const meta: SessionMetaDoc = { kind: 'sessionMeta', dir: summary.dir, identity, title };
if (previous?.kind !== 'sessionMeta' || previous.identity !== identity || previous.dir !== summary.dir || previous.title !== title) {
await db.set(metaKey, meta);
}
const wireFiles = await collectWireFiles(summary.dir);
const seenPaths = new Set(wireFiles.map((file) => file.path));

Expand All @@ -523,16 +593,16 @@ export class SearchIndexCore {
}

for (const file of wireFiles) {
await this.syncWireFile(db, summary, file);
await this.syncWireFile(db, { ...summary, title, sessionIdentity: identity }, file);
}

const title = summary.title ?? '';
const titleKey = `${summary.id}/$title`;
const existing = db.get(titleKey);
if (title.length > 0) {
if (existing?.kind !== 'title' || existing.text !== title) {
const doc: TitleDoc = {
kind: 'title',
sessionIdentity: identity,
sessionId: summary.id,
workspaceId: summary.workspaceId,
sessionTitle: title,
Expand All @@ -547,10 +617,7 @@ export class SearchIndexCore {
} else if (existing !== undefined) {
await db.del(titleKey);
}
if (db.get(SESSION_META_PREFIX + summary.id) === undefined) {
const sessionMeta: SessionMetaDoc = { kind: 'sessionMeta' };
await db.set(SESSION_META_PREFIX + summary.id, sessionMeta);
}
return true;
}

private async deleteFileDocs(db: MiniDb<SearchDoc>, meta: FileMetaDoc): Promise<void> {
Expand All @@ -562,7 +629,7 @@ export class SearchIndexCore {

private async syncWireFile(
db: MiniDb<SearchDoc>,
summary: SyncSessionInput,
summary: SyncSessionInput & { readonly sessionIdentity: string },
file: WireFileRef,
): Promise<void> {
let st: { size: number; mtimeMs: number; ino: number };
Expand Down Expand Up @@ -693,7 +760,7 @@ export class SearchIndexCore {

private collectWireLine(
ops: BatchInputOp<SearchDoc>[],
summary: SyncSessionInput,
summary: SyncSessionInput & { readonly sessionIdentity: string },
file: WireFileRef,
line: string,
lineOffset: number,
Expand All @@ -717,6 +784,7 @@ export class SearchIndexCore {
const stepOrdinal = e.stepUuid !== undefined ? stepState.byUuid[e.stepUuid] : undefined;
const doc: MessageDoc = {
kind: 'message',
sessionIdentity: summary.sessionIdentity,
sessionId: summary.id,
workspaceId: summary.workspaceId,
sessionTitle: summary.title ?? '',
Expand Down Expand Up @@ -865,14 +933,65 @@ export class SearchIndexCore {
const boundary = page.kind === 'keyset' ? page.boundary : undefined;
const matched = matchDocs(q, candidates, boundary, budget);
incomplete ??= matched.incomplete;
const { pageRows, hasMore } = paginateRows(q, page, matched.rows);
const index = this.readIndexView(serveDb, freshnessStale);
const sources = new Map<string, SessionMetaDoc | undefined>();
for (const row of matched.rows) {
const id = row.value.sessionId;
if (sources.has(id)) continue;
if (Date.now() > budget.deadlineAt) {
incomplete ??= 'deadline';
break;
}
const meta = serveDb.get(SESSION_META_PREFIX + id);
sources.set(id, meta?.kind === 'sessionMeta' ? meta : undefined);
}
const identities = new Map<string, string | undefined>();
const visible: MatchedRow[] = [];
let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<null>((resolve) => {
deadlineTimer = setTimeout(() => resolve(null), Math.max(0, budget.deadlineAt - Date.now()));
deadlineTimer.unref?.();
});
try {
for (const row of matched.rows) {
if (Date.now() > budget.deadlineAt) {
incomplete ??= 'deadline';
break;
}
const meta = sources.get(row.value.sessionId);
if (meta?.dir === undefined || row.value.sessionIdentity === undefined || meta.identity !== row.value.sessionIdentity) {
freshnessStale = true;
continue;
}
if (!identities.has(meta.dir)) {
try {
const identity = await queryDirectoryIdentity(meta.dir, budget.deadlineAt, deadline);
if (identity === null || Date.now() > budget.deadlineAt) {
incomplete ??= 'deadline';
break;
}
identities.set(meta.dir, identity);
} catch (error) {
throw new GlobalSearchError('index_unavailable', `cannot verify search source: ${errorMessage(error)}`);
}
}
if (identities.get(meta.dir) === row.value.sessionIdentity) {
visible.push({ ...row, value: { ...row.value, sessionTitle: meta.title ?? '' } });
} else {
freshnessStale = true;
}
}
} finally {
if (deadlineTimer !== undefined) clearTimeout(deadlineTimer);
}
const { pageRows, hasMore } = paginateRows(q, page, visible);
return {
kind: 'page',
rows: pageRows,
hasMore,
incomplete,
generation,
index: this.readIndexView(serveDb, freshnessStale),
index: { ...index, freshnessStale: freshnessStale || this.db !== serveDb },
};
}

Expand Down Expand Up @@ -924,7 +1043,7 @@ export class SearchIndexCore {
generation: this.generation,
readOnly: this.db?.readOnly === true,
lockToken: this.lockToken,
degraded: this.lastRefreshError?.message,
degraded: this.lastRefreshError?.message ?? (stats?.kind === 'stats' ? stats.degraded : undefined),
lifecycle: this.lifecycleState(),
};
}
Expand All @@ -939,7 +1058,7 @@ export class SearchIndexCore {
documents: stats?.kind === 'stats' ? stats.documents : 0,
readOnly: handle?.readOnly === true,
freshnessStale: true,
degraded: this.lastRefreshError?.message,
degraded: this.lastRefreshError?.message ?? (stats?.kind === 'stats' ? stats.degraded : undefined),
lockToken: this.lockToken,
};
}
Expand All @@ -955,7 +1074,7 @@ export class SearchIndexCore {
documents,
readOnly: db.readOnly,
freshnessStale,
degraded: this.lastRefreshError?.message,
degraded: this.lastRefreshError?.message ?? (stats?.kind === 'stats' ? stats.degraded : undefined),
lockToken: this.lockToken,
};
}
Expand Down
2 changes: 1 addition & 1 deletion packages/kap-server/src/search/searchService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -620,7 +620,7 @@ export class GlobalSearchService implements IGlobalSearchService {
return {
sessionId: doc.sessionId,
workspaceId: doc.workspaceId,
sessionTitle: this.summaries.get(doc.sessionId)?.title ?? doc.sessionTitle,
sessionTitle: doc.sessionTitle,
agentId: doc.agentId,
role: doc.role,
snippet:
Expand Down
1 change: 1 addition & 0 deletions packages/kap-server/test/search/searchRoute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ describe('server-v2 /api/v1/search', () => {
].join('\n') + '\n',
'utf8',
);
await writeFile(join(home, 'sessions', WS, 's1', 'state.json'), JSON.stringify({ title: '苹果询价' }));
const summaries: SessionSummary[] = [
{
id: 's1',
Expand Down
Loading
Loading