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

Write session metadata atomically to avoid unreadable sessions after interrupted updates.
35 changes: 33 additions & 2 deletions packages/agent-core/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -816,15 +816,46 @@ export class Session {
const text = JSON.stringify(this.metadata, null, 2);
const write = async () => {
await this.persistenceKaos.mkdir(this.options.homedir, { parents: true, existOk: true });
await this.persistenceKaos.writeText(this.metadataPath, text);
if (this.persistenceKaos.writeTextAtomic === undefined) {
await this.persistenceKaos.writeText(this.metadataPath, text);
return;
}
await this.persistenceKaos.writeTextAtomic(this.metadataPath, text);
};
this.writeMetadataPromise = this.writeMetadataPromise.then(write, write);
return this.writeMetadataPromise;
}

async readMetadata() {
const text = await this.persistenceKaos.readText(this.metadataPath);
this.metadata = JSON.parse(text);
let parsed: unknown;
try {
parsed = JSON.parse(text) as unknown;
} catch (error) {
throw new KimiError(
ErrorCodes.SESSION_STATE_INVALID,
'Session state.json contains invalid JSON',
{ cause: error },
);
}
if (
typeof parsed !== 'object' ||
parsed === null ||
Array.isArray(parsed) ||
!('agents' in parsed) ||
typeof parsed.agents !== 'object' ||
parsed.agents === null ||
Array.isArray(parsed.agents)
) {
throw new KimiError(
ErrorCodes.SESSION_STATE_INVALID,
'Session state.json does not contain valid agent metadata',
);
}
// SAFETY: legacy and v2 session files have different top-level metadata
// fields, but both guarantee an object-valued `agents` map. Individual
// agent fields are refined by the existing resume paths that consume them.
this.metadata = parsed as SessionMeta;
return this.metadata;
}

Expand Down
11 changes: 6 additions & 5 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 type { Dirent } from 'node:fs';
import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { cp, mkdir, readdir, readFile, rm, stat } from 'node:fs/promises';
import * as nodePath from 'node:path';
import { dirname, isAbsolute, join, relative, resolve } from 'pathe';

Expand All @@ -24,6 +24,7 @@ import {
type AgentRecord,
type AgentRecordOf,
} from '../../agent/records';
import { atomicWrite } from '#/utils/fs';

const SessionSummaryStateSchema = z.object({
archived: z.boolean().optional(),
Expand Down Expand Up @@ -215,7 +216,7 @@ export class SessionStore {
title: normalized,
isCustomTitle: true,
};
await writeFile(statePath, `${JSON.stringify(next, null, 2)}\n`, 'utf-8');
await atomicWrite(statePath, `${JSON.stringify(next, null, 2)}\n`);
}

async archive(id: string): Promise<SessionSummary> {
Expand All @@ -238,7 +239,7 @@ export class SessionStore {
archived: true,
updatedAt: now,
};
await writeFile(statePath, `${JSON.stringify(next, null, 2)}\n`, 'utf-8');
await atomicWrite(statePath, `${JSON.stringify(next, null, 2)}\n`);
return this.summaryFromDir(id, entry.sessionDir, entry.workDir);
}

Expand Down Expand Up @@ -510,7 +511,7 @@ export class SessionStore {
agents: rewriteAgentHomedirs(parsed['agents'], sourceDir, targetDir),
custom: forkCustomMetadata(parsed['custom'], input.metadata),
};
await writeFile(statePath, `${JSON.stringify(next, null, 2)}\n`, 'utf-8');
await atomicWrite(statePath, `${JSON.stringify(next, null, 2)}\n`);
return next;
}

Expand Down Expand Up @@ -625,7 +626,7 @@ async function truncateForkedSessionAtTurn(
lastPrompt: mainSlice.lastPrompt,
agents: retainedAgents,
};
await writeFile(join(sessionDir, 'state.json'), `${JSON.stringify(next, null, 2)}\n`, 'utf-8');
await atomicWrite(join(sessionDir, 'state.json'), `${JSON.stringify(next, null, 2)}\n`);
return next;
}

Expand Down
27 changes: 14 additions & 13 deletions packages/agent-core/src/utils/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import { randomBytes } from 'node:crypto';
import { closeSync, fsyncSync, openSync } from 'node:fs';
import * as nodeFs from 'node:fs';
import { open, rename, unlink } from 'node:fs/promises';
import { open, rename, stat, unlink } from 'node:fs/promises';
import { dirname } from 'pathe';

/**
Expand Down Expand Up @@ -153,26 +153,27 @@ export async function atomicWrite(
): Promise<void> {
const hex = randomBytes(4).toString('hex');
const tmpPath = `${filePath}.tmp.${process.pid}.${hex}`;
let mode = 0o600;
try {
mode = (await stat(filePath)).mode & 0o777;
} catch (error) {
const fileError = error as NodeJS.ErrnoException;
if (fileError.code !== 'ENOENT') throw error;
}
let renamed = false;
try {
const fh = await open(tmpPath, 'w');
const fh = await open(tmpPath, 'wx', 0o600);
try {
await fh.writeFile(content);
await fh.chmod(mode);
await (_syncOverride ?? syncFd)(fh.fd);
} finally {
await fh.close();
}
// Windows `fs.rename` maps to MoveFileEx and fails with EPERM if
// the target is held by another handle. Pre-unlinking
// before the rename turns this into the POSIX-style "replace" case.
if (process.platform === 'win32') {
try {
await unlink(filePath);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== 'ENOENT') throw error;
}
}
// Do not unlink the destination first. If replacement is unavailable
// (for example because Windows has an open handle), failing the rename
// preserves the previous complete file instead of creating a missing-file
// window.
await rename(tmpPath, filePath);
renamed = true;
} finally {
Expand Down
14 changes: 13 additions & 1 deletion packages/agent-core/test/harness/model-alias-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,7 +529,19 @@ max_context_size = 1000000
expect(records).toContainEqual({
event: 'session_load_failed',
sessionId: created.id,
properties: { reason: 'SyntaxError' },
properties: { reason: 'session.state_invalid' },
});
});

it('resumeSession returns session.state_invalid when state.json contains malformed JSON', async () => {
const rpc = await createTestRpc();
const created = await rpc.createSession({ workDir });
await writeFile(join(created.sessionDir, 'state.json'), '{bad json', 'utf-8');

const freshRpc = await createTestRpc();

await expect(freshRpc.resumeSession({ sessionId: created.id })).rejects.toMatchObject({
code: 'session.state_invalid',
});
});

Expand Down
38 changes: 38 additions & 0 deletions packages/agent-core/test/session/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,44 @@ afterEach(async () => {
});

describe('Session.init', () => {
it('writeMetadata uses atomic persistence when direct replacement is unavailable', async () => {
const workDir = await makeTempDir();
const sessionDir = await makeTempDir();
const local = testKaos.withCwd(workDir);
const persistenceKaos = new Proxy(local, {
get(target, property, receiver) {
if (property === 'writeText') {
return async () => {
throw new Error('direct replacement is unavailable');
};
}
if (property === 'writeTextAtomic') {
return async (path: string, data: string) => {
await writeFile(path, data, 'utf-8');
return data.length;
};
}
return Reflect.get(target, property, receiver);
},
});
const session = new Session({
id: 'test-atomic-session-metadata',
kaos: local,
persistenceKaos,
homedir: sessionDir,
rpc: createSessionRpc([]),
skills: { explicitDirs: [join(workDir, 'missing-skills')] },
providerManager: testProviderManager(),
});

try {
await session.writeMetadata();
await expect(access(join(sessionDir, 'state.json'))).resolves.toBeUndefined();
} finally {
await session.close();
}
});

it('runs an isolated system-trigger turn and records the latest AGENTS as a system reminder', async () => {
const workDir = await makeTempDir();
const sessionDir = await makeTempDir();
Expand Down
26 changes: 25 additions & 1 deletion packages/agent-core/test/session/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Wiring: real temporary session storage; no stubbed collaborators.
* Run: pnpm exec vitest run test/session/store.test.ts
*/
import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises';
import { chmod, mkdtemp, mkdir, realpath, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

Expand Down Expand Up @@ -61,6 +61,30 @@ describe('SessionStore', () => {
return wd;
}

it.skipIf(process.platform === 'win32')(
'rename restores state.json permissions masked by umask',
async () => {
const workDir = await trackWorkDir('rename-mode');
const sessionDir = await seedSessionDir(homeDir, workDir, 'sess_rename_mode');
await appendSessionIndexEntry(homeDir, {
sessionId: 'sess_rename_mode',
sessionDir,
workDir,
});
const statePath = join(sessionDir, 'state.json');
await chmod(statePath, 0o640);
const previousUmask = process.umask(0o077);

try {
await store.rename('sess_rename_mode', 'Private session');
} finally {
process.umask(previousUmask);
}

expect((await stat(statePath)).mode & 0o777).toBe(0o640);
},
);

describe('create', () => {
it('creates a session when an earlier index entry points to a missing directory', async () => {
const workDir = await trackWorkDir('stale-create');
Expand Down
11 changes: 11 additions & 0 deletions packages/kaos/src/kaos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,17 @@ export interface Kaos {
data: string,
options?: { mode?: 'w' | 'a'; encoding?: BufferEncoding },
): Promise<number>;
/**
* Replace a text file without exposing partially written content.
*
* Implementations write to a temporary file in the target directory and
* atomically replace `path` when the backing filesystem supports it.
*/
writeTextAtomic?(
path: string,
data: string,
options?: { encoding?: BufferEncoding },
): Promise<number>;
/** Create a directory at `path`. */
mkdir(path: string, options?: { parents?: boolean; existOk?: boolean }): Promise<void>;

Expand Down
50 changes: 49 additions & 1 deletion packages/kaos/src/local.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import type { ChildProcess, SpawnOptions } from 'node:child_process';
import { spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import {
appendFile,
lstat,
mkdir,
open,
readdir,
readFile,
rename,
stat,
unlink,
writeFile,
} from 'node:fs/promises';
import { homedir } from 'node:os';
import { isAbsolute, join, normalize } from 'pathe';
import { dirname, isAbsolute, join, normalize } from 'pathe';
import type { Readable, Writable } from 'node:stream';

import { detectEnvironmentFromNode, type Environment } from './environment';
Expand Down Expand Up @@ -669,6 +672,51 @@ export class LocalKaos implements Kaos {
return data.length;
}

/** Atomically replace a local text file using a same-directory temporary file. */
async writeTextAtomic(
path: string,
data: string,
options?: { encoding?: BufferEncoding },
): Promise<number> {
const resolved = this._resolvePath(path);
const encoding = options?.encoding ?? 'utf-8';
const suffix = randomBytes(4).toString('hex');
const tempPath = `${resolved}.tmp.${process.pid}.${suffix}`;
let mode = 0o600;
try {
mode = (await stat(resolved)).mode & 0o777;
} catch (error) {
const fileError = error as NodeJS.ErrnoException;
if (fileError.code !== 'ENOENT') throw error;
}
let replaced = false;
try {
const handle = await open(tempPath, 'wx', 0o600);
try {
await handle.writeFile(data, encoding);
await handle.chmod(mode);
await handle.sync();
} finally {
await handle.close();
}
await rename(tempPath, resolved);
replaced = true;
if (process.platform !== 'win32') {
const directory = await open(dirname(resolved), 'r');
try {
await directory.sync();
} finally {
await directory.close();
}
}
return data.length;
} finally {
if (!replaced) {
await unlink(tempPath).catch(() => {});
}
}
}

async mkdir(path: string, options?: { parents?: boolean; existOk?: boolean }): Promise<void> {
const resolved = this._resolvePath(path);
const parents = options?.parents ?? false;
Expand Down
Loading