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
10 changes: 0 additions & 10 deletions .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,6 @@
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": [
"@moonshot-ai/acp-adapter",
"@moonshot-ai/agent-core",
"@moonshot-ai/kaos",
"@moonshot-ai/kimi-code-oauth",
"@moonshot-ai/kimi-telemetry",
"@moonshot-ai/kimi-web",
"@moonshot-ai/kosong",
"@moonshot-ai/migration-legacy",
"@moonshot-ai/protocol",
"@moonshot-ai/server",
"@moonshot-ai/server-e2e",
"@moonshot-ai/vis",
"@moonshot-ai/vis-server",
Expand Down
6 changes: 6 additions & 0 deletions .changeset/fast-snapshot-reader.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/server": minor
"@moonshot-ai/kimi-code": minor
---

Speed up session snapshot loading with a direct disk reader and a request timeout safeguard, keeping the previous path as a legacy fallback.
2 changes: 2 additions & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
"#/services/gateway/*": "./src/services/gateway/*.ts",
"#/services/question": "./src/services/question/index.ts",
"#/services/question/*": "./src/services/question/*.ts",
"#/services/snapshot": "./src/services/snapshot/index.ts",
"#/services/snapshot/*": "./src/services/snapshot/*.ts",
"#/*": "./src/*.ts"
},
"exports": {
Expand Down
167 changes: 105 additions & 62 deletions packages/server/src/routes/snapshot.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,22 @@
/**
* `GET /sessions/{session_id}/snapshot` — IM-style initial sync.
*
* Assembles an atomic-at-a-watermark view for client rebuild:
* **Reader strategy** (controlled by `KIMI_SNAPSHOT_READER`):
*
* as_of_seq / epoch ← `IWSBroadcastService.getSnapshotState`
* session ← `ISessionService.get`
* messages (asc) ← `IMessageService.list` (most recent page)
* in_flight_turn ← broadcast's `InFlightTurnTracker`
* pending_approvals ← server `ApprovalService.listPending`
* pending_questions ← server `QuestionService.listPending`
* - `auto` (default) — delegate to `ISnapshotService`, which reads
* `state.json` + `wire.jsonl` directly from disk and bypasses the heavy
* `core.rpc.listSessions`/`resumeSession`/`getContext` chain. Sub-200ms
* warm / sub-1s cold; legacy path was 5s+ p99 under load.
* - `legacy` — fall back to the old `ISessionService.get` + `IMessageService.list`
* assembly with the 3-attempt watermark-stability retry. Pure operator
* escape hatch; no silent per-request fallback.
*
* Watermark stability: the durable seq is read before and after assembly;
* if a durable event landed in between, assembly retries (bounded). Durable
* events are low-frequency (turn/tool boundaries — deltas are volatile and
* don't advance seq), so this converges almost immediately. After the
* retries are exhausted the latest watermark is returned — the client's
* seq-guard drops any overlap on replay.
* **Timeout**: the new path races against a hard `KIMI_SNAPSHOT_TIMEOUT_MS`
* ceiling (default 4000ms, well under traefik's 5s cut-off). Timeout returns
* 50001 with a structured log line so the gateway never sees a 499.
*
* **Error mapping**: `SessionNotFoundError` → 40401; everything else falls
* **Error mapping**: `SnapshotNotFoundError` / `SessionNotFoundError` → 40401;
* `SnapshotTimeoutError` → 50001 (`snapshot.timeout`); everything else falls
* through to the global error handler (→ 50001).
*/

Expand All @@ -27,7 +26,7 @@ import {
type Message,
type Session,
} from '@moonshot-ai/protocol';
import { IApprovalService, IMessageService, IPromptService, IQuestionService, ISessionService, SessionNotFoundError, type IInstantiationService } from '@moonshot-ai/agent-core';
import { IApprovalService, IMessageService, IPromptService, IQuestionService, ISessionService, ILogService, SessionNotFoundError, type IInstantiationService } from '@moonshot-ai/agent-core';
import { z } from 'zod';


Expand All @@ -36,6 +35,12 @@ import { defineRoute } from '../middleware/defineRoute';
import type { ApprovalService } from '#/services/approval/approvalService';
import type { QuestionService } from '#/services/question/questionService';
import { IWSBroadcastService } from '#/services/gateway';
import {
ISnapshotService,
SnapshotNotFoundError,
SnapshotTimeoutError,
loadSnapshotConfig,
} from '#/services/snapshot';

interface SnapshotRouteHost {
get(
Expand All @@ -55,13 +60,16 @@ const sessionIdParamSchema = z.object({
/** Messages included in the snapshot page (most recent, ascending order). */
const SNAPSHOT_MESSAGE_PAGE_SIZE = 100;

/** Bounded watermark-stability retries (see module header). */
/** Bounded watermark-stability retries for the legacy fallback. */
const MAX_ASSEMBLY_ATTEMPTS = 3;

export function registerSnapshotRoutes(
app: SnapshotRouteHost,
ix: IInstantiationService,
): void {
const config = loadSnapshotConfig();
const useReader = config.mode !== 'legacy';

const route = defineRoute(
{
method: 'GET',
Expand All @@ -73,61 +81,96 @@ export function registerSnapshotRoutes(
tags: ['sessions'],
},
async (req, reply) => {
const { session_id } = req.params;
try {
const { session_id } = req.params;
const data = await ix.invokeFunction(async (a) => {
const broadcast = a.get(IWSBroadcastService);
const sessionService = a.get(ISessionService);
const messageService = a.get(IMessageService);
const promptService = a.get(IPromptService);
const approvals = a.get(IApprovalService) as ApprovalService;
const questions = a.get(IQuestionService) as QuestionService;

let snapState = await broadcast.getSnapshotState(session_id);
let session: Session | undefined;
let items: Message[] = [];
let hasMore = false;

for (let attempt = 0; attempt < MAX_ASSEMBLY_ATTEMPTS; attempt++) {
session = await sessionService.get(session_id);
const page = await messageService.list(session_id, {
page_size: SNAPSHOT_MESSAGE_PAGE_SIZE,
});
// IMessageService returns newest-first; snapshot serves ascending.
items = [...page.items].reverse();
hasMore = page.has_more;

const post = await broadcast.getSnapshotState(session_id);
const stable = post.seq === snapState.seq && post.epoch === snapState.epoch;
snapState = post;
if (stable) break;
}

const currentPromptId = promptService.getCurrentPromptId(session_id);
const inFlightTurn = snapState.inFlightTurn;
if (inFlightTurn !== null && currentPromptId !== undefined) {
inFlightTurn.current_prompt_id = currentPromptId;
}

return {
as_of_seq: snapState.seq,
epoch: snapState.epoch,
session: session!,
messages: { items, has_more: hasMore },
in_flight_turn: inFlightTurn,
pending_approvals: approvals.listPending(session_id),
pending_questions: questions.listPending(session_id),
};
});
const data = useReader
? await readViaSnapshotService(ix, session_id, config.timeoutMs)
: await readViaLegacyAssembly(ix, session_id);
reply.send(okEnvelope(data, req.id));
} catch (err) {
if (err instanceof SessionNotFoundError) {
if (err instanceof SnapshotNotFoundError || err instanceof SessionNotFoundError) {
reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, req.id));
return;
}
if (err instanceof SnapshotTimeoutError) {
ix.invokeFunction((a) => {
a.get(ILogService).warn(
{ sid: session_id, duration_ms: err.timeoutMs },
'snapshot.timeout',
);
});
reply.send(errEnvelope(ErrorCode.INTERNAL_ERROR, err.message, req.id));
return;
}
throw err;
}
},
);
app.get(route.path, route.options, route.handler as Parameters<SnapshotRouteHost['get']>[2]);
}

async function readViaSnapshotService(
ix: IInstantiationService,
sid: string,
timeoutMs: number,
) {
let timer: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new SnapshotTimeoutError(sid, timeoutMs)), timeoutMs);
timer.unref?.();
});
try {
return await Promise.race([
ix.invokeFunction((a) => a.get(ISnapshotService).read(sid)),
timeoutPromise,
]);
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}

async function readViaLegacyAssembly(ix: IInstantiationService, sid: string) {
return ix.invokeFunction(async (a) => {
const broadcast = a.get(IWSBroadcastService);
const sessionService = a.get(ISessionService);
const messageService = a.get(IMessageService);
const promptService = a.get(IPromptService);
const approvals = a.get(IApprovalService) as ApprovalService;
const questions = a.get(IQuestionService) as QuestionService;

let snapState = await broadcast.getSnapshotState(sid);
let session: Session | undefined;
let items: Message[] = [];
let hasMore = false;

for (let attempt = 0; attempt < MAX_ASSEMBLY_ATTEMPTS; attempt++) {
session = await sessionService.get(sid);
const page = await messageService.list(sid, {
page_size: SNAPSHOT_MESSAGE_PAGE_SIZE,
});
items = [...page.items].reverse();
hasMore = page.has_more;

const post = await broadcast.getSnapshotState(sid);
const stable = post.seq === snapState.seq && post.epoch === snapState.epoch;
snapState = post;
if (stable) break;
}

const currentPromptId = promptService.getCurrentPromptId(sid);
const inFlightTurn = snapState.inFlightTurn;
if (inFlightTurn !== null && currentPromptId !== undefined) {
inFlightTurn.current_prompt_id = currentPromptId;
}

return {
as_of_seq: snapState.seq,
epoch: snapState.epoch,
session: session!,
messages: { items, has_more: hasMore },
in_flight_turn: inFlightTurn,
pending_approvals: approvals.listPending(sid),
pending_questions: questions.listPending(sid),
};
});
}
7 changes: 7 additions & 0 deletions packages/server/src/services/serviceCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { IWSGateway } from '#/services/gateway/wsGateway';
import { WSGateway } from '#/services/gateway/wsGatewayService';
import { IWSBroadcastService } from '#/services/gateway/wsBroadcast';
import { WSBroadcastService } from '#/services/gateway/wsBroadcastService';
import { ISnapshotService, SnapshotService, loadSnapshotConfig } from '#/services/snapshot';

export interface ServerServiceCollectionOptions {
readonly server: ServerStartOptions;
Expand All @@ -35,6 +36,8 @@ export function createServerServiceCollection(
): ServiceCollection {
const { server, app, pinoLogger, envService } = input;

const snapshotConfig = loadSnapshotConfig();

const services = new ServiceCollection(
...getSingletonServiceDescriptors(),
[IConnectionRegistry, new SyncDescriptor(ConnectionRegistry, [], false)],
Expand All @@ -44,6 +47,10 @@ export function createServerServiceCollection(
[Services.IQuestionService, new SyncDescriptor(QuestionService, [], false)],
);

if (snapshotConfig.mode !== 'legacy') {
services.set(ISnapshotService, new SyncDescriptor(SnapshotService, [], false));
}

services.set(Services.ILogService, new PinoLoggerAdapter(pinoLogger));
services.set(IRestGateway, new FastifyRestGateway(app));
services.set(Services.IEnvironmentService, envService);
Expand Down
4 changes: 4 additions & 0 deletions packages/server/src/services/snapshot/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { ISnapshotService, SnapshotNotFoundError, SnapshotTimeoutError } from './snapshot';
export { SnapshotService } from './snapshotService';
export { loadSnapshotConfig } from './snapshotConfig';
export type { SnapshotConfig, SnapshotReaderMode } from './snapshotConfig';
42 changes: 42 additions & 0 deletions packages/server/src/services/snapshot/snapshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* `ISnapshotService` — server-layer reader that backs `GET /sessions/{sid}/snapshot`.
*
* Bypasses `ICoreProcessService.rpc.listSessions`/`resumeSession`/`getContext`
* by reading `state.json` + `agents/main/wire.jsonl` directly from disk. See
* `snapshotService.ts` for the rationale and cache invariants.
*/

import { createDecorator } from '@moonshot-ai/agent-core';
import type { SessionSnapshotResponse } from '@moonshot-ai/protocol';

export interface ISnapshotService {
readonly _serviceBrand: undefined;

/** Assemble the atomic snapshot for `sid`. Throws `SnapshotNotFoundError` when the session does not exist on disk. */
read(sid: string): Promise<SessionSnapshotResponse>;
}

// eslint-disable-next-line @typescript-eslint/no-redeclare
export const ISnapshotService = createDecorator<ISnapshotService>('snapshotService');

/** Sentinel — route maps to 40401. */
export class SnapshotNotFoundError extends Error {
readonly sessionId: string;
constructor(sessionId: string) {
super(`session ${sessionId} does not exist`);
this.name = 'SnapshotNotFoundError';
this.sessionId = sessionId;
}
}

/** Sentinel — route maps to 50001 with code `SNAPSHOT_TIMEOUT`. */
export class SnapshotTimeoutError extends Error {
readonly sessionId: string;
readonly timeoutMs: number;
constructor(sessionId: string, timeoutMs: number) {
super(`snapshot ${sessionId} timed out after ${timeoutMs}ms`);
this.name = 'SnapshotTimeoutError';
this.sessionId = sessionId;
this.timeoutMs = timeoutMs;
}
}
35 changes: 35 additions & 0 deletions packages/server/src/services/snapshot/snapshotConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Env-driven knobs for `ISnapshotService`. Read once at boot.
*
* KIMI_SNAPSHOT_READER 'auto' (default) | 'legacy'
* KIMI_SNAPSHOT_TIMEOUT_MS integer ms hard ceiling on the route (default 4000)
* KIMI_SNAPSHOT_CACHE_LIMIT LRU entries (default 32)
*/

export type SnapshotReaderMode = 'auto' | 'legacy';

export interface SnapshotConfig {
readonly mode: SnapshotReaderMode;
readonly timeoutMs: number;
readonly cacheLimit: number;
}

const DEFAULT_TIMEOUT_MS = 4000;
const DEFAULT_CACHE_LIMIT = 32;

function parseInteger(value: string | undefined, fallback: number, min: number): number {
if (value === undefined) return fallback;
const n = Number.parseInt(value, 10);
if (!Number.isFinite(n) || n < min) return fallback;
return n;
}

export function loadSnapshotConfig(env: NodeJS.ProcessEnv = process.env): SnapshotConfig {
const rawMode = env['KIMI_SNAPSHOT_READER']?.trim().toLowerCase();
const mode: SnapshotReaderMode = rawMode === 'legacy' ? 'legacy' : 'auto';
return {
mode,
timeoutMs: parseInteger(env['KIMI_SNAPSHOT_TIMEOUT_MS'], DEFAULT_TIMEOUT_MS, 100),
cacheLimit: parseInteger(env['KIMI_SNAPSHOT_CACHE_LIMIT'], DEFAULT_CACHE_LIMIT, 1),
};
}
Loading
Loading