diff --git a/.changeset/session-index-stray-files.md b/.changeset/session-index-stray-files.md new file mode 100644 index 00000000000..2077fcd02db --- /dev/null +++ b/.changeset/session-index-stray-files.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix recent sessions missing from the session list when the sessions directory contains stray files. diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexProjector.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexProjector.ts index 774bead6110..ec2130319ac 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexProjector.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexProjector.ts @@ -198,14 +198,14 @@ export class SessionIndexProjector { } private async scanAuthoritative(): Promise { - const { storage, docs, sessionsScope } = this.deps; + const { storage, docs, sessionsScope, log } = this.deps; const summaries: SessionSummary[] = []; const counts = new Map(); 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); }); diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts index aba8842511a..eb55317feec 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts @@ -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), diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts index 9c75068f034..8ee0e68be17 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts @@ -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'; @@ -171,27 +176,49 @@ export async function mapBounded( return out; } +async function stateFileMtime( + storage: IFileSystemStorageService, + scope: string, + log: ILogService | undefined, +): Promise { + 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 { 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 { 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; diff --git a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts index a9bc6181d0f..96664dd0ac8 100644 --- a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts +++ b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts @@ -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 }); @@ -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 });