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/rebuild-session-index-on-boot.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/agent-core/src/rpc/core-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
...session.metadata,
createdAt: new Date(summary.createdAt).toISOString(),
updatedAt: new Date(summary.updatedAt).toISOString(),
workDir,
...(summary.title !== undefined
? {
title: summary.title,
Expand Down
5 changes: 5 additions & 0 deletions packages/agent-core/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, AgentMeta>;
custom: Record<string, any>;
}
Expand Down Expand Up @@ -989,6 +993,7 @@ export class Session {
}

export * from './subagent-host';
export * from './store';

function initCompletionReminder(agentsMd: string): string {
const latest =
Expand Down
24 changes: 20 additions & 4 deletions packages/agent-core/src/session/store/session-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Promise<void>>();

export function sessionIndexPath(homeDir: string): string {
return join(homeDir, 'session_index.jsonl');
}
Expand All @@ -16,8 +24,14 @@ export async function appendSessionIndexEntry(
entry: SessionIndexEntry,
): Promise<void> {
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(
Expand All @@ -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;
Expand Down
101 changes: 98 additions & 3 deletions packages/agent-core/src/session/store/session-store.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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(),
});

Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -186,6 +187,98 @@ 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) &&
existing.workDir === workDir
) {
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<string | undefined> {
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,
Expand Down Expand Up @@ -275,6 +368,7 @@ export class SessionStore {
private async writeForkedState(
input: ForkSessionRecordInput,
sourceDir: string,
sourceWorkDir: string,
targetDir: string,
): Promise<Record<string, unknown>> {
const statePath = join(targetDir, 'state.json');
Expand Down Expand Up @@ -303,6 +397,7 @@ export class SessionStore {
...parsed,
createdAt: now,
updatedAt: now,
workDir: sourceWorkDir,
title,
isCustomTitle: input.title === undefined ? parsed['isCustomTitle'] === true : true,
forkedFrom: input.sourceId,
Expand All @@ -327,7 +422,7 @@ export class SessionStore {
]);
return {
id,
workDir,
workDir: state?.workDir ?? workDir,
sessionDir,
createdAt: timestampOrFallback(dirStat.birthtimeMs, dirStat.ctimeMs),
updatedAt: Math.max(
Expand Down
Loading
Loading