From 6e11cffbaea4c2070abe93b46d1aa878a827e27b Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Sun, 5 Jul 2026 13:36:45 +0800 Subject: [PATCH 1/3] feat(session): rebuild session index on boot and self-describe workDir - persist workDir into state.json so session dirs are self-describing and summaries do not depend on the index's one-way-hashed workDir - relax readSessionIndex so a stale or non-absolute index workDir no longer drops an otherwise valid entry - serialize in-process index appends to avoid torn jsonl lines - add SessionStore.reindex() and run it once at server boot so the scan-free request path can find sessions whose index line is missing or stale --- packages/agent-core/src/rpc/core-impl.ts | 1 + packages/agent-core/src/session/index.ts | 5 + .../src/session/store/session-index.ts | 24 ++- .../src/session/store/session-store.ts | 95 +++++++++++- .../agent-core/test/session/store.test.ts | 144 ++++++++++++++++++ packages/server/src/start.ts | 14 +- 6 files changed, 275 insertions(+), 8 deletions(-) create mode 100644 packages/agent-core/test/session/store.test.ts diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 5866d0b466..77593af912 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -303,6 +303,7 @@ export class KimiCore implements PromisableMethods { ...session.metadata, createdAt: new Date(summary.createdAt).toISOString(), updatedAt: new Date(summary.updatedAt).toISOString(), + workDir, ...(summary.title !== undefined ? { title: summary.title, diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 478c775b61..d66bc6f970 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -123,6 +123,10 @@ export interface SessionMeta { isCustomTitle: boolean; lastPrompt?: string; forkedFrom?: string; + /** Absolute working directory the session was created in. Persisted so the + * session directory is self-describing and the global session index does not + * have to be trusted for the (one-way-hashed) workDir. */ + workDir?: string; agents: Record; custom: Record; } @@ -989,6 +993,7 @@ export class Session { } export * from './subagent-host'; +export * from './store'; function initCompletionReminder(agentsMd: string): string { const latest = diff --git a/packages/agent-core/src/session/store/session-index.ts b/packages/agent-core/src/session/store/session-index.ts index 0753f4ec6a..06dc8799fa 100644 --- a/packages/agent-core/src/session/store/session-index.ts +++ b/packages/agent-core/src/session/store/session-index.ts @@ -7,6 +7,14 @@ export interface SessionIndexEntry { readonly workDir: string; } +// Per-homeDir append chain. Within one process, concurrent index appends are +// serialized so two lines can never be interleaved at the filesystem layer. +// Cross-process, a single short line written with O_APPEND is atomic on POSIX +// (well under PIPE_BUF), so this closes the realistic same-process tearing gap +// without taking a file lock. A failed append is reported to its caller but does +// not poison the chain for later appends. +const appendQueues = new Map>(); + export function sessionIndexPath(homeDir: string): string { return join(homeDir, 'session_index.jsonl'); } @@ -16,8 +24,14 @@ export async function appendSessionIndexEntry( entry: SessionIndexEntry, ): Promise { const indexPath = sessionIndexPath(homeDir); - await mkdir(dirname(indexPath), { recursive: true, mode: 0o700 }); - await appendFile(indexPath, `${JSON.stringify(entry)}\n`, 'utf-8'); + const line = `${JSON.stringify(entry)}\n`; + const previous = appendQueues.get(homeDir) ?? Promise.resolve(); + const next = previous.then(async () => { + await mkdir(dirname(indexPath), { recursive: true, mode: 0o700 }); + await appendFile(indexPath, line, 'utf-8'); + }); + appendQueues.set(homeDir, next.then(() => undefined, () => undefined)); + return next; } export async function readSessionIndex( @@ -39,13 +53,15 @@ export async function readSessionIndex( if (entry === undefined) continue; const sessionDir = resolve(entry.sessionDir); if (!isAbsolute(entry.sessionDir)) continue; - if (!isAbsolute(entry.workDir)) continue; if (!isPathInside(sessionsDir, sessionDir)) continue; if (basename(sessionDir) !== entry.sessionId) continue; + // `workDir` is no longer authoritative: summaries prefer the workDir stored + // in each session's self-describing state.json, so a stale or relocated + // index workDir must not drop an otherwise valid entry. result.set(entry.sessionId, { sessionId: entry.sessionId, sessionDir, - workDir: resolve(entry.workDir), + workDir: entry.workDir, }); } return result; diff --git a/packages/agent-core/src/session/store/session-store.ts b/packages/agent-core/src/session/store/session-store.ts index dfa012c0d3..f65915b12a 100644 --- a/packages/agent-core/src/session/store/session-store.ts +++ b/packages/agent-core/src/session/store/session-store.ts @@ -1,5 +1,5 @@ import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; -import { dirname, isAbsolute, join, relative } from 'pathe'; +import { dirname, isAbsolute, join, relative, resolve } from 'pathe'; import { z } from 'zod'; @@ -16,6 +16,7 @@ const SessionSummaryStateSchema = z.object({ isCustomTitle: z.boolean().optional(), lastPrompt: z.string().optional(), title: z.string().optional(), + workDir: z.string().optional(), custom: z.record(z.string(), z.unknown()).optional(), }); @@ -95,7 +96,7 @@ export class SessionStore { errorOnExist: true, }); await dropForkedSessionFiles(targetDir); - const forkedState = await this.writeForkedState(input, source.sessionDir, targetDir); + const forkedState = await this.writeForkedState(input, source.sessionDir, source.workDir, targetDir); await appendForkedMarkers(forkedState); const summary = await this.summaryFromDir(input.targetId, targetDir, source.workDir); await appendSessionIndexEntry(this.homeDir, { @@ -186,6 +187,92 @@ export class SessionStore { return this.listAll(includeArchive); } + /** + * Rebuild the global session index from the session directories on disk. + * + * The bucket directory name is a one-way hash of the workDir, so the workDir + * can only be recovered from each session's self-describing `state.json` + * (`workDir`, falling back to `custom.cwd` for older sessions). Sessions that + * record no workDir, or whose recorded workDir does not match the bucket they + * live in, are left untouched rather than writing a misleading entry. + * + * The index is append-only and `readSessionIndex` lets later lines override + * earlier ones for the same id, so appending a corrected line both adds + * missing entries and repairs stale ones. Best-effort: never throws. + */ + async reindex(): Promise<{ scanned: number; added: number; repaired: number }> { + const index = await readSessionIndex(this.homeDir, this.sessionsDir); + let bucketEntries; + try { + bucketEntries = await readdir(this.sessionsDir, { withFileTypes: true }); + } catch { + return { scanned: 0, added: 0, repaired: 0 }; + } + + let scanned = 0; + let added = 0; + let repaired = 0; + + for (const bucket of bucketEntries) { + if (!bucket.isDirectory()) continue; + const bucketDir = join(this.sessionsDir, bucket.name); + let sessionEntries; + try { + sessionEntries = await readdir(bucketDir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of sessionEntries) { + if (!entry.isDirectory()) continue; + const id = entry.name; + if (!isSafeSessionId(id)) continue; + const sessionDir = join(bucketDir, id); + const workDir = await this.recoverWorkDir(sessionDir); + if (workDir === undefined) continue; + scanned++; + + let expectedDir: string; + try { + expectedDir = this.sessionDirFor({ id, workDir }); + } catch { + continue; + } + // Refuse to index a session whose recorded workDir does not match the + // bucket it lives in (corrupt or foreign state). + if (resolve(sessionDir) !== resolve(expectedDir)) continue; + + const existing = index.get(id); + if (existing !== undefined && resolve(existing.sessionDir) === resolve(sessionDir)) continue; + + await appendSessionIndexEntry(this.homeDir, { sessionId: id, sessionDir, workDir }); + index.set(id, { sessionId: id, sessionDir, workDir }); + if (existing === undefined) added++; + else repaired++; + } + } + return { scanned, added, repaired }; + } + + private async recoverWorkDir(sessionDir: string): Promise { + const state = await readOptionalState(sessionDir); + if (state?.workDir !== undefined) { + try { + return normalizeWorkDir(state.workDir); + } catch { + return undefined; + } + } + const legacyCwd = state?.custom?.['cwd']; + if (typeof legacyCwd === 'string' && legacyCwd.length > 0) { + try { + return normalizeWorkDir(legacyCwd); + } catch { + return undefined; + } + } + return undefined; + } + private async listWorkDir( workDir: string, includeArchive: boolean, @@ -275,6 +362,7 @@ export class SessionStore { private async writeForkedState( input: ForkSessionRecordInput, sourceDir: string, + sourceWorkDir: string, targetDir: string, ): Promise> { const statePath = join(targetDir, 'state.json'); @@ -303,6 +391,7 @@ export class SessionStore { ...parsed, createdAt: now, updatedAt: now, + workDir: sourceWorkDir, title, isCustomTitle: input.title === undefined ? parsed['isCustomTitle'] === true : true, forkedFrom: input.sourceId, @@ -327,7 +416,7 @@ export class SessionStore { ]); return { id, - workDir, + workDir: state?.workDir ?? workDir, sessionDir, createdAt: timestampOrFallback(dirStat.birthtimeMs, dirStat.ctimeMs), updatedAt: Math.max( diff --git a/packages/agent-core/test/session/store.test.ts b/packages/agent-core/test/session/store.test.ts new file mode 100644 index 0000000000..a8c3253f9d --- /dev/null +++ b/packages/agent-core/test/session/store.test.ts @@ -0,0 +1,144 @@ +import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { SessionStore } from '../../src/session/store/session-store'; +import { appendSessionIndexEntry, readSessionIndex } from '../../src/session/store/session-index'; +import { encodeWorkDirKey, normalizeWorkDir } from '../../src/session/store/workdir-key'; + +async function makeWorkDir(label: string): Promise { + const root = await mkdtemp(join(tmpdir(), `kimi-store-wd-${label}-`)); + // realpath so a symlinked tmpdir (e.g. /tmp -> /private/tmp on macOS) agrees + // with the workDir key used by the store. + return normalizeWorkDir(await realpath(root)); +} + +async function seedSessionDir( + homeDir: string, + workDir: string, + sessionId: string, + state: Record = {}, +): Promise { + const dir = join(homeDir, 'sessions', encodeWorkDirKey(workDir), sessionId); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, 'state.json'), JSON.stringify({ workDir, ...state }), 'utf-8'); + return dir; +} + +describe('SessionStore', () => { + let homeDir: string; + let store: SessionStore; + const tempRoots: string[] = []; + + beforeEach(async () => { + homeDir = await mkdtemp(join(tmpdir(), 'kimi-store-home-')); + store = new SessionStore(homeDir); + }); + + afterEach(async () => { + await rm(homeDir, { recursive: true, force: true }); + for (const root of tempRoots) { + await rm(root, { recursive: true, force: true }); + } + tempRoots.length = 0; + }); + + async function trackWorkDir(label: string): Promise { + const wd = await makeWorkDir(label); + tempRoots.push(wd); + return wd; + } + + describe('summaryFromDir (via get/list)', () => { + it('prefers workDir from state.json over the index entry', async () => { + const indexWorkDir = await trackWorkDir('index'); + const stateWorkDir = await trackWorkDir('state'); + const sessionId = 'session_pref'; + + // Index says one workDir; state.json (self-describing) says another. + await store.create({ id: sessionId, workDir: indexWorkDir }); + const dir = join(homeDir, 'sessions', encodeWorkDirKey(indexWorkDir), sessionId); + await writeFile(join(dir, 'state.json'), JSON.stringify({ workDir: stateWorkDir }), 'utf-8'); + + const summary = await store.get(sessionId); + expect(summary.workDir).toBe(stateWorkDir); + }); + }); + + describe('reindex', () => { + it('adds an index entry for an on-disk session missing from the index', async () => { + const workDir = await trackWorkDir('missing'); + const sessionId = 'session_missing'; + await seedSessionDir(homeDir, workDir, sessionId); + + expect(await store.list({})).toHaveLength(0); + + const stats = await store.reindex(); + expect(stats).toEqual({ scanned: 1, added: 1, repaired: 0 }); + + const listed = await store.list({}); + expect(listed.map((s) => s.id)).toEqual([sessionId]); + expect(listed[0]?.workDir).toBe(workDir); + }); + + it('repairs an index entry that points at a stale sessionDir', async () => { + const workDir = await trackWorkDir('stale'); + const sessionId = 'session_stale'; + const realDir = await seedSessionDir(homeDir, workDir, sessionId); + + // Seed a decoy dir inside the sessions tree with a matching basename so it + // passes index integrity checks, then point the index at it instead of the + // real dir. + const decoyDir = join(homeDir, 'sessions', 'wd_decoy_000000000000', sessionId); + await mkdir(decoyDir, { recursive: true }); + await appendSessionIndexEntry(homeDir, { + sessionId, + sessionDir: decoyDir, + workDir, + }); + + const stats = await store.reindex(); + expect(stats).toEqual({ scanned: 1, added: 0, repaired: 1 }); + + const index = await readSessionIndex(homeDir, store.sessionsDir); + expect(index.get(sessionId)?.sessionDir).toBe(realDir); + }); + + it('leaves a session unindexed when it records no recoverable workDir', async () => { + const workDir = await trackWorkDir('noworkdir'); + const sessionId = 'session_noworkdir'; + // state.json present but without workDir or custom.cwd. + await seedSessionDir(homeDir, workDir, sessionId, { workDir: undefined }); + // Overwrite state.json to truly drop the workDir field seedSessionDir adds. + const dir = join(homeDir, 'sessions', encodeWorkDirKey(workDir), sessionId); + await writeFile(join(dir, 'state.json'), JSON.stringify({ title: 'legacy' }), 'utf-8'); + + const stats = await store.reindex(); + expect(stats).toEqual({ scanned: 0, added: 0, repaired: 0 }); + expect(await store.list({})).toHaveLength(0); + }); + }); + + describe('readSessionIndex', () => { + it('keeps an entry whose index workDir is non-absolute, using state.json workDir', async () => { + const workDir = await trackWorkDir('relaxed'); + const sessionId = 'session_relaxed'; + await seedSessionDir(homeDir, workDir, sessionId); + + const sessionDir = join(homeDir, 'sessions', encodeWorkDirKey(workDir), sessionId); + // Non-absolute, previously would have dropped the entry. + await appendSessionIndexEntry(homeDir, { + sessionId, + sessionDir, + workDir: 'not/an/absolute/path', + }); + + const listed = await store.list({}); + expect(listed.map((s) => s.id)).toEqual([sessionId]); + // state.json wins, so the bogus index workDir never surfaces. + expect(listed[0]?.workDir).toBe(workDir); + }); + }); +}); diff --git a/packages/server/src/start.ts b/packages/server/src/start.ts index db6a6ea382..26c53304fa 100644 --- a/packages/server/src/start.ts +++ b/packages/server/src/start.ts @@ -1,4 +1,4 @@ -import { InstantiationService, resolveConfigPath, resolveKimiHome, setUnexpectedErrorHandler, IApprovalService, IAuthSummaryService, IEnvironmentService, IEventService, ICoreProcessService, IModelCatalogService, IMcpService, IMessageService, IOAuthService, IFileStore, IFsGitService, IFsSearchService, IFsService, IFsWatcher, ILogService, IPromptService, IQuestionService, ISessionService, ISkillService, ITaskService, ITerminalService, IToolService, IWorkspaceFsService, IWorkspaceRegistry, FsPathEscapesError, FsWatchLimitError, FsWatcherService, SessionNotFoundError, createConnectionLookup, resolveSafePath, type ServiceIdentifier, type CoreProcessServiceOptions } from '@moonshot-ai/agent-core'; +import { InstantiationService, resolveConfigPath, resolveKimiHome, setUnexpectedErrorHandler, IApprovalService, IAuthSummaryService, IEnvironmentService, IEventService, ICoreProcessService, IModelCatalogService, IMcpService, IMessageService, IOAuthService, IFileStore, IFsGitService, IFsSearchService, IFsService, IFsWatcher, ILogService, IPromptService, IQuestionService, ISessionService, ISkillService, ITaskService, ITerminalService, IToolService, IWorkspaceFsService, IWorkspaceRegistry, FsPathEscapesError, FsWatchLimitError, FsWatcherService, SessionNotFoundError, SessionStore, createConnectionLookup, resolveSafePath, type ServiceIdentifier, type CoreProcessServiceOptions } from '@moonshot-ai/agent-core'; import { ErrorCode, createAsyncApiDocument } from '@moonshot-ai/protocol'; import Fastify from 'fastify'; import { promises as fspPromises } from 'node:fs'; @@ -217,6 +217,18 @@ export async function startServer(opts: ServerStartOptions): Promise/server.token` // (0600; generated once on first boot and reused across restarts) and an From 6bb3712a2ffd8878f9404ae01803670edf640cea Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Sun, 5 Jul 2026 13:42:56 +0800 Subject: [PATCH 2/3] chore: add changeset for session index rebuild --- .changeset/rebuild-session-index-on-boot.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/rebuild-session-index-on-boot.md diff --git a/.changeset/rebuild-session-index-on-boot.md b/.changeset/rebuild-session-index-on-boot.md new file mode 100644 index 0000000000..e9bd68cb3b --- /dev/null +++ b/.changeset/rebuild-session-index-on-boot.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix sessions that exist on disk but were missing from the session list or returned 404 on direct access, by rebuilding the session index at server startup and keeping it consistent. From f6f9d3f7866064acea8e9588960426ae53e6d70c Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Sun, 5 Jul 2026 13:55:20 +0800 Subject: [PATCH 3/3] fix(session): repair index entries with a stale workDir during reindex --- .../src/session/store/session-store.ts | 8 ++++- .../agent-core/test/session/store.test.ts | 31 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/agent-core/src/session/store/session-store.ts b/packages/agent-core/src/session/store/session-store.ts index f65915b12a..d323799336 100644 --- a/packages/agent-core/src/session/store/session-store.ts +++ b/packages/agent-core/src/session/store/session-store.ts @@ -242,7 +242,13 @@ export class SessionStore { if (resolve(sessionDir) !== resolve(expectedDir)) continue; const existing = index.get(id); - if (existing !== undefined && resolve(existing.sessionDir) === resolve(sessionDir)) continue; + if ( + existing !== undefined && + resolve(existing.sessionDir) === resolve(sessionDir) && + existing.workDir === workDir + ) { + continue; + } await appendSessionIndexEntry(this.homeDir, { sessionId: id, sessionDir, workDir }); index.set(id, { sessionId: id, sessionDir, workDir }); diff --git a/packages/agent-core/test/session/store.test.ts b/packages/agent-core/test/session/store.test.ts index a8c3253f9d..5df0f763c8 100644 --- a/packages/agent-core/test/session/store.test.ts +++ b/packages/agent-core/test/session/store.test.ts @@ -106,6 +106,37 @@ describe('SessionStore', () => { expect(index.get(sessionId)?.sessionDir).toBe(realDir); }); + it('repairs an index entry whose sessionDir is correct but workDir is stale', async () => { + const workDir = await trackWorkDir('staleworkdir'); + const sessionId = 'session_staleworkdir'; + // Legacy state: no top-level workDir, only custom.cwd, so summaryFromDir + // falls back to the index entry's workDir. + const realDir = join(homeDir, 'sessions', encodeWorkDirKey(workDir), sessionId); + await mkdir(realDir, { recursive: true }); + await writeFile( + join(realDir, 'state.json'), + JSON.stringify({ custom: { cwd: workDir } }), + 'utf-8', + ); + + // Index points at the right dir but carries a bogus workDir. + await appendSessionIndexEntry(homeDir, { + sessionId, + sessionDir: realDir, + workDir: '/totally/bogus/path', + }); + + // Before reindex the summary surfaces the bogus index workDir. + expect((await store.get(sessionId)).workDir).toBe('/totally/bogus/path'); + + const stats = await store.reindex(); + expect(stats).toEqual({ scanned: 1, added: 0, repaired: 1 }); + + // Reindex appended a corrected line, so the summary now uses the recovered + // workDir instead of the stale index value. + expect((await store.get(sessionId)).workDir).toBe(workDir); + }); + it('leaves a session unindexed when it records no recoverable workDir', async () => { const workDir = await trackWorkDir('noworkdir'); const sessionId = 'session_noworkdir';