Skip to content
Merged
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/session-index-stray-files.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix recent sessions missing from the session list when the sessions directory contains stray files.
Original file line number Diff line number Diff line change
Expand Up @@ -198,14 +198,14 @@ export class SessionIndexProjector {
}

private async scanAuthoritative(): Promise<AuthoritativeScan> {
const { storage, docs, sessionsScope } = this.deps;
const { storage, docs, sessionsScope, log } = this.deps;
const summaries: SessionSummary[] = [];
const counts = new Map<string, { active: number; archived: number }>();
let sourceMaxMtimeMs = (await storage.mtime(SESSION_INDEX_SCOPE, SESSION_INDEX_KEY)) ?? 0;
for (const workspaceId of await listWorkspaceIds(storage, sessionsScope)) {
const sessionIds = await listSessionIds(storage, sessionsScope, workspaceId);
const found = await mapBounded(sessionIds, SCAN_CONCURRENCY, async (sessionId) => {
const mtime = await sessionStateMaxMtime(storage, sessionsScope, workspaceId, sessionId);
const mtime = await sessionStateMaxMtime(storage, sessionsScope, workspaceId, sessionId, log);
if (mtime > sourceMaxMtimeMs) sourceMaxMtimeMs = mtime;
return readSessionSummary(docs, sessionsScope, workspaceId, sessionId);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ export class FileSessionIndex extends Disposable implements ISessionIndex {
const published = manifest.sourceMaxMtimeMs;
if (published === undefined) return false;
try {
return (await scanSessionsMaxMtime(this.storage, this.sessionsScope)) <= published;
return (await scanSessionsMaxMtime(this.storage, this.sessionsScope, this.log)) <= published;
} catch (error) {
this.log.warn('session index freshness check failed; re-projecting', {
error: String(error),
Expand Down
35 changes: 31 additions & 4 deletions packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { ILogService } from '#/_base/log/log';
import { SESSION_INDEX_KEY, SESSION_INDEX_SCOPE } from '#/app/workspace/workspaceAlias';
import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore';
import { IFileSystemStorageService } from '#/persistence/interface/storage';
import {
IFileSystemStorageService,
StorageError,
StorageErrors,
} from '#/persistence/interface/storage';

import { CHILD_SESSION_KIND, CHILD_SESSION_KIND_KEY, type SessionSummary } from './sessionIndex';

Expand Down Expand Up @@ -171,27 +176,49 @@ export async function mapBounded<T, R>(
return out;
}

async function stateFileMtime(
storage: IFileSystemStorageService,
scope: string,
log: ILogService | undefined,
): Promise<number | undefined> {
try {
return await storage.mtime(scope, META_KEY);
} catch (error) {
if (
error instanceof StorageError &&
error.code === StorageErrors.codes.STORAGE_IO_FAILED &&
error.details?.['errno'] === 'ENOTDIR'
) {
log?.warn('session index skips a non-directory entry', { path: error.details['path'] });
return undefined;
}
throw error;
}
}

export async function sessionStateMaxMtime(
storage: IFileSystemStorageService,
sessionsScope: string,
workspaceId: string,
sessionId: string,
log?: ILogService,
): Promise<number> {
const base = `${sessionsScope}/${workspaceId}/${sessionId}`;
const direct = await storage.mtime(base, META_KEY);
const nested = await storage.mtime(`${base}/${META_SCOPE}`, META_KEY);
const direct = await stateFileMtime(storage, base, log);
const nested = await stateFileMtime(storage, `${base}/${META_SCOPE}`, log);
return Math.max(direct ?? 0, nested ?? 0);
}

export async function scanSessionsMaxMtime(
storage: IFileSystemStorageService,
sessionsScope: string,
log?: ILogService,
): Promise<number> {
let max = (await storage.mtime(SESSION_INDEX_SCOPE, SESSION_INDEX_KEY)) ?? 0;
for (const workspaceId of await listWorkspaceIds(storage, sessionsScope)) {
const sessionIds = await listSessionIds(storage, sessionsScope, workspaceId);
const mtimes = await mapBounded(sessionIds, MTIME_SCAN_CONCURRENCY, (sessionId) =>
sessionStateMaxMtime(storage, sessionsScope, workspaceId, sessionId),
sessionStateMaxMtime(storage, sessionsScope, workspaceId, sessionId, log),
);
for (const mtime of mtimes) {
if (mtime > max) max = mtime;
Expand Down
29 changes: 16 additions & 13 deletions packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,19 +221,6 @@ describe('FileSessionIndex (legacy)', () => {
expect(await store.count({ workspaceIds: ['wd_unknown'] })).toBe(0);
});

it('listRecent merges a workspace-id set into one recency-ordered page', async () => {
const otherId = encodeWorkDirKey('/home/user/other');
await seedSession('a1', { createdAt: 1, updatedAt: 1 });
await seedSession('a3', { createdAt: 3, updatedAt: 3 });
await seedSession('b2', { createdAt: 2, updatedAt: 2 }, otherId);
await seedSession('b4', { createdAt: 4, updatedAt: 4 }, otherId);

const store = build();
const page = await store.listRecent({ workspaceIds: [workspaceId, otherId] });
expect(page.items.map((s) => s.id)).toEqual(['b4', 'a3', 'b2', 'a1']);
expect(page.items[0]?.workspaceId).toBe(otherId);
});

it('listRecent applies limit after the cross-bucket merge', async () => {
const otherId = encodeWorkDirKey('/home/user/other');
await seedSession('a1', { createdAt: 1, updatedAt: 1 });
Expand Down Expand Up @@ -517,6 +504,22 @@ describe('FileSessionIndex (read model)', () => {
expect(await store.count({ workspaceIds: [workspaceId], includeArchived: true })).toBe(2);
});

it('prepare skips stray files and state-less directories instead of failing the projection', async () => {
await seedSession('active', { title: 'hello', createdAt: 1, updatedAt: 2 });
await fsp.writeFile(join(sessionsDir, 'workspace.json'), '{}');
await fsp.writeFile(join(sessionsDir, workspaceId, 'workspace.json'), '{}');
await fsp.writeFile(join(sessionsDir, workspaceId, '.DS_Store'), 'junk');
await fsp.mkdir(join(sessionsDir, workspaceId, 'no-state'), { recursive: true });

const store = build();
const status = await store.prepare();
expect(status).toEqual({ state: 'ready', generation: 1, degradedCount: 0 });

const page = await store.listRecent({ workspaceIds: [workspaceId] });
expect(page.items.map((s) => s.id)).toEqual(['active']);
expect(await store.count({ workspaceIds: [workspaceId] })).toBe(1);
});

it('serves warm reads without touching the session directories', async () => {
await seedSession('a', { title: 'a', createdAt: 1, updatedAt: 2 });
await seedSession('b', { title: 'b', createdAt: 2, updatedAt: 3 });
Expand Down
Loading